Skip to content
Closed
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
7 changes: 7 additions & 0 deletions src/CloudServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions src/Disks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace craft\cloud;

use CraftCms\Cms\Cms;
use CraftCms\Cms\Filesystem\Events\UploadSessionStarting;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;

/**
* The project's bucket, as Laravel disks: one for assets, which volumes can use, and one for staging uploads.
*
* They share their credentials, so uploads go from the browser straight to the bucket, and are copied into Craft
* Cloud volumes without leaving it.
*/
class Disks
{
public const ASSETS = 'craft-cloud';
public const UPLOADS = 'craft-cloud-uploads';
private const CDN_BASE_URL = 'https://cdn.craft.cloud';

/**
* Registers the disks, if the bucket is known.
*/
public static function configure(): void
{
$bucket = $_SERVER['CRAFT_CLOUD_PROJECT_ID'] ?? null;
$environmentId = $_SERVER['CRAFT_CLOUD_ENVIRONMENT_ID'] ?? null;
$region = $_SERVER['CRAFT_CLOUD_REGION'] ?? $_SERVER['AWS_REGION'] ?? null;

if (!$bucket || !$environmentId || !$region) {
return;
}

$endpoint = $_SERVER['CRAFT_CLOUD_STORAGE_ENDPOINT'] ?? null;
// Without an access key, the SDK uses the function's own credentials
$disk = array_filter(
[
'driver' => '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;
}
});
}
}
91 changes: 91 additions & 0 deletions tests/Feature/CloudServiceProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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],
]);
});
14 changes: 14 additions & 0 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading