From d9d0c475186dd7e5c3293531373bb652125c3a13 Mon Sep 17 00:00:00 2001 From: Brandon Kelly Date: Thu, 12 Nov 2026 12:45:31 +0000 Subject: [PATCH] Issue direct upload URLs from Cloud filesystems Implement Craft's DirectUploadFsInterface (5.12.2+) on Fs, so Craft's MCP server can send files straight to S3 instead of through Lambda's 6 MB request limit. The interface is shimmed for older Craft versions. Amp-Thread-ID: https://ampcode.com/threads/T-54e734d2-d69f-42d7-9f3b-a6a8e281ad1d Co-authored-by: Amp --- src/fs/DirectUploadFsInterface.php | 16 ++++++++ src/fs/Fs.php | 39 ++++++++++++++++-- tests/unit/FsTest.php | 65 ++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 src/fs/DirectUploadFsInterface.php create mode 100644 tests/unit/FsTest.php diff --git a/src/fs/DirectUploadFsInterface.php b/src/fs/DirectUploadFsInterface.php new file mode 100644 index 0000000..6c16e9e --- /dev/null +++ b/src/fs/DirectUploadFsInterface.php @@ -0,0 +1,16 @@ +presignRequest($command, $path, $expiresAt, $config)->getUri(); + } + + /** + * @return array{url: string, method: string, headers: array}|null + */ + public function getDirectUploadUrl(string $path, DateTimeInterface $expiresAt): ?array + { + if ($this->useLocalFs) { + return null; + } + + $request = $this->presignRequest('PutObject', $path, $expiresAt); + parse_str($request->getUri()->getQuery(), $query); + $headers = []; + + // The signature covers headers such as the object metadata, so the client must send them + foreach (explode(';', $query['X-Amz-SignedHeaders'] ?? '') as $name) { + if ($name !== '' && $name !== 'host') { + $headers[$name] = $request->getHeaderLine($name); + } + } + + return [ + 'url' => (string)$request->getUri(), + 'method' => $request->getMethod(), + 'headers' => $headers, + ]; + } + + private function presignRequest(string $command, string $path, DateTimeInterface $expiresAt, array $config = []): RequestInterface { if ($this->useLocalFs) { throw new InvalidConfigException(); @@ -340,12 +373,10 @@ public function presignedUrl(string $command, string $path, DateTimeInterface $e 'Key' => $this->createBucketPath($path)->toString(), ] + $commandConfig); - $request = $this->getClient()->createPresignedRequest( + return $this->getClient()->createPresignedRequest( $command, $expiresAt, ); - - return (string)$request->getUri(); } catch (Throwable $exception) { throw new FsException($exception->getMessage(), 0, $exception); } diff --git a/tests/unit/FsTest.php b/tests/unit/FsTest.php new file mode 100644 index 0000000..69321fe --- /dev/null +++ b/tests/unit/FsTest.php @@ -0,0 +1,65 @@ +previousModule = Module::getInstance(); + $module = new Module('cloud'); + Module::setInstance($module); + $module->getConfig() + ->projectId('project-id') + ->environmentId('environment-id') + ->accessKey('access-key') + ->accessSecret('access-secret') + ->region('us-west-2'); + } + + protected function _after(): void + { + Module::setInstance($this->previousModule); + parent::_after(); + } + + public function testDirectUploadUrlSignsPutWithHeadersTheClientMustSend(): void + { + $upload = Craft::createObject(TmpFs::class)->getDirectUploadUrl('mcp-uploads/abc', new DateTimeImmutable('+15 minutes')); + $url = parse_url($upload['url']); + parse_str($url['query'], $query); + + $this->assertSame('PUT', $upload['method']); + $this->assertSame('/environment-id/storage/tmp/mcp-uploads/abc', $url['path']); + $this->assertSame('host;x-amz-meta-visibility', $query['X-Amz-SignedHeaders']); + $this->assertSame(['x-amz-meta-visibility' => 'private'], $upload['headers']); + } + + public function testDirectUploadUrlIncludesEverySignedMetadataHeader(): void + { + $fs = Craft::createObject(['class' => AssetsFs::class, 'hasUrls' => true]); + $fs->useLocalFs = false; + $upload = $fs->getDirectUploadUrl('photo.jpg', new DateTimeImmutable('+15 minutes')); + + $this->assertSame(['x-amz-meta-max-age', 'x-amz-meta-visibility'], array_keys($upload['headers'])); + $this->assertSame('public', $upload['headers']['x-amz-meta-visibility']); + } + + public function testDirectUploadUrlIsUnavailableForLocalFilesystems(): void + { + $fs = Craft::createObject(TmpFs::class); + $fs->useLocalFs = true; + + $this->assertNull($fs->getDirectUploadUrl('mcp-uploads/abc', new DateTimeImmutable('+15 minutes'))); + } +}