From abdde2d45673e80c5d3bb384aab4cf7ef36f7702 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 27 Oct 2026 07:24:12 -0700 Subject: [PATCH] Upload assets straight to the project's bucket Registers the project's bucket as two Laravel disks, one for assets and one for staging uploads, and stages Craft's upload sessions on the second, so browsers send files straight to S3 with Craft's own S3 uploader instead of a custom one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o --- src/CloudServiceProvider.php | 7 ++ src/Disks.php | 82 +++++++++++++++++++ tests/Feature/CloudServiceProviderTest.php | 91 ++++++++++++++++++++++ tests/TestCase.php | 14 ++++ 4 files changed, 194 insertions(+) create mode 100644 src/Disks.php 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;