Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/fs/DirectUploadFsInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace craft\cloud\fs;

use craft\base\DirectUploadFsInterface as CraftDirectUploadFsInterface;

// Craft added DirectUploadFsInterface in 5.12.2; earlier versions get an empty interface.
if (interface_exists(CraftDirectUploadFsInterface::class)) {
interface DirectUploadFsInterface extends CraftDirectUploadFsInterface
{
}
} else {
interface DirectUploadFsInterface
{
}
}
39 changes: 35 additions & 4 deletions src/fs/Fs.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@
use League\Uri\Contracts\SegmentedPathInterface;
use League\Uri\Contracts\UriInterface;
use League\Uri\Modifier;
use Psr\Http\Message\RequestInterface;
use Throwable;
use yii\base\InvalidConfigException;

/**
* @property-read ?string $settingsHtml
*/
abstract class Fs extends FlysystemFs
abstract class Fs extends FlysystemFs implements DirectUploadFsInterface
{
// Most image headers are much smaller, but large EXIF/XMP/ICC blocks can
// push JPEG dimensions farther in. Stay bounded to avoid full downloads.
Expand Down Expand Up @@ -327,6 +328,38 @@ protected function visibility(): string
}

public function presignedUrl(string $command, string $path, DateTimeInterface $expiresAt, array $config = []): string
{
return (string)$this->presignRequest($command, $path, $expiresAt, $config)->getUri();
}

/**
* @return array{url: string, method: string, headers: array<string, string>}|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();
Expand All @@ -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);
}
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/FsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace craft\cloud\tests\unit;

use Codeception\Test\Unit;
use Craft;
use craft\cloud\fs\AssetsFs;
use craft\cloud\fs\TmpFs;
use craft\cloud\Module;
use DateTimeImmutable;

class FsTest extends Unit
{
private ?Module $previousModule = null;

protected function _before(): void
{
parent::_before();
$this->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')));
}
}
Loading