diff --git a/src/CloudServiceProvider.php b/src/CloudServiceProvider.php index db55fb91..f00d525a 100644 --- a/src/CloudServiceProvider.php +++ b/src/CloudServiceProvider.php @@ -23,6 +23,13 @@ public function register(): void $this->configureQueue(); $this->configureCache(); $this->configureLogging(); + Disks::configure(); + } + + public function boot(): void + { + // Only does anything on Craft Cloud, where the disks are registered + Disks::stageUploads(); } private function configureLogging(): void diff --git a/src/Disks.php b/src/Disks.php new file mode 100644 index 00000000..85ab0926 --- /dev/null +++ b/src/Disks.php @@ -0,0 +1,82 @@ + 's3', + 'bucket' => $bucket, + 'region' => $region, + 'key' => $_SERVER['CRAFT_CLOUD_ACCESS_KEY'] ?? null, + 'secret' => $_SERVER['CRAFT_CLOUD_ACCESS_SECRET'] ?? null, + 'token' => $_SERVER['CRAFT_CLOUD_ACCESS_TOKEN'] ?? null, + 'endpoint' => $endpoint, + 'use_path_style_endpoint' => $endpoint ? true : null, + 'visibility' => 'private', + 'throw' => true, + ], + static fn(mixed $value): bool => $value !== null, + ); + $cdnBaseUrl = rtrim($_SERVER['CRAFT_CLOUD_CDN_BASE_URL'] ?? self::CDN_BASE_URL, characters: '/'); + + Config::set('filesystems.disks.' . self::ASSETS, [ + ...$disk, + 'root' => "{$environmentId}/assets", + 'url' => "{$cdnBaseUrl}/{$environmentId}/assets", + ]); + Config::set('filesystems.disks.' . self::UPLOADS, [ + ...$disk, + 'root' => "{$environmentId}/storage/tmp/uploads", + ]); + } + + /** + * Stages upload sessions on the uploads disk, unless the project chooses its own disk for them. + */ + public static function stageUploads(): void + { + if (!Config::has('filesystems.disks.' . self::UPLOADS)) { + return; + } + + Event::listen(UploadSessionStarting::class, static function (UploadSessionStarting $event): void { + if (Cms::config()->uploadSessionDisk === null) { + $event->disk ??= self::UPLOADS; + } + }); + } +} diff --git a/tests/Feature/CloudServiceProviderTest.php b/tests/Feature/CloudServiceProviderTest.php index 3a20d06d..53307d1d 100644 --- a/tests/Feature/CloudServiceProviderTest.php +++ b/tests/Feature/CloudServiceProviderTest.php @@ -3,6 +3,12 @@ declare(strict_types=1); use craft\cloud\tests\Support\DnsRecords; +use CraftCms\Cms\Config\GeneralConfig; +use CraftCms\Cms\Filesystem\Events\UploadSessionStarting; +use Illuminate\Container\Container; +use Illuminate\Events\Dispatcher; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Event; it('does not configure cloud services outside Craft Cloud', function (): void { $this->registerProvider(); @@ -96,3 +102,88 @@ 'stores' => ['craft-cloud-redis', 'database', 'array'], ]); }); + +describe('filesystems', function (): void { + beforeEach(function (): void { + $this->server('CRAFT_CLOUD', '1'); + $this->server('CRAFT_CLOUD_PROJECT_ID', 'project-bucket'); + $this->server('CRAFT_CLOUD_ENVIRONMENT_ID', 'environment-id'); + $this->server('CRAFT_CLOUD_REGION', 'us-west-2'); + }); + + afterEach(function (): void { + Container::setInstance(null); + }); + + it('registers the project bucket as disks for assets and staged uploads', function (): void { + [$key, $secret, $token] = [bin2hex(random_bytes(8)), bin2hex(random_bytes(8)), bin2hex(random_bytes(8))]; + $this->server('CRAFT_CLOUD_ACCESS_KEY', $key); + $this->server('CRAFT_CLOUD_ACCESS_SECRET', $secret); + $this->server('CRAFT_CLOUD_ACCESS_TOKEN', $token); + + $this->registerProvider(); + + $disk = [ + 'driver' => 's3', + 'bucket' => 'project-bucket', + 'region' => 'us-west-2', + 'key' => $key, + 'secret' => $secret, + 'token' => $token, + 'visibility' => 'private', + 'throw' => true, + ]; + + expect($this->config->get('filesystems.disks.craft-cloud')) + ->toBe([ + ...$disk, + 'root' => 'environment-id/assets', + 'url' => 'https://cdn.craft.cloud/environment-id/assets', + ]) + ->and($this->config->get('filesystems.disks.craft-cloud-uploads')) + ->toBe([...$disk, 'root' => 'environment-id/storage/tmp/uploads']); + }); + + it('uses the function’s own credentials without an access key, and a storage endpoint if there is one', function (): void { + $this->server('CRAFT_CLOUD_STORAGE_ENDPOINT', 'http://minio:9000'); + $this->server('CRAFT_CLOUD_CDN_BASE_URL', 'https://cdn.example.test/'); + + $this->registerProvider(); + + expect($this->config->get('filesystems.disks.craft-cloud')) + ->not + ->toHaveKeys(['key', 'secret', 'token']) + ->toMatchArray([ + 'endpoint' => 'http://minio:9000', + 'use_path_style_endpoint' => true, + 'url' => 'https://cdn.example.test/environment-id/assets', + ]); + }); + + it('doesn’t register disks without a bucket', function (): void { + unset($_SERVER['CRAFT_CLOUD_PROJECT_ID']); + + $this->registerProvider(); + + expect($this->config->get('filesystems.disks.craft-cloud'))->toBeNull(); + }); + + it('stages upload sessions in the bucket, unless the project chooses its own disk', function ( + ?string $configured, + ?string $expected, + ): void { + $this->app->instance('events', new Dispatcher($this->app)); + Container::setInstance($this->app); + $this->app->instance(GeneralConfig::class, GeneralConfig::create()->uploadSessionDisk($configured)); + $this->registerProvider(); + $this->bootProvider(); + + $event = new UploadSessionStarting(Request::create('/'), 'handler', 'photo.jpg', 3, []); + Event::dispatch($event); + + expect($event->disk)->toBe($expected); + })->with([ + 'default' => [null, 'craft-cloud-uploads'], + 'chosen by the project' => ['project-uploads', null], + ]); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 183be666..9a68b566 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -17,6 +17,15 @@ abstract class TestCase extends BaseTestCase 'CRAFT_CLOUD', 'CRAFT_CLOUD_CACHE_SRV', 'CRAFT_CLOUD_SQS_URL', + 'CRAFT_CLOUD_PROJECT_ID', + 'CRAFT_CLOUD_ENVIRONMENT_ID', + 'CRAFT_CLOUD_REGION', + 'AWS_REGION', + 'CRAFT_CLOUD_ACCESS_KEY', + 'CRAFT_CLOUD_ACCESS_SECRET', + 'CRAFT_CLOUD_ACCESS_TOKEN', + 'CRAFT_CLOUD_STORAGE_ENDPOINT', + 'CRAFT_CLOUD_CDN_BASE_URL', ]; protected Container $app; @@ -110,6 +119,11 @@ protected function registerProvider(): void new CloudServiceProvider($this->app)->register(); } + protected function bootProvider(): void + { + new CloudServiceProvider($this->app)->boot(); + } + protected function server(string $key, string $value): void { $_SERVER[$key] = $value;