Skip to content
Draft
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
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
"bref/bref": "^3",
"bref/extra-php-extensions": "^3",
"bref/laravel-bridge": "^3.0",
"craftcms/cms": "^6",
"craftcms/cms": "^6.0.0-beta.1",
"craftcms/http-message-signatures": "^1.0",
"guzzlehttp/guzzle": "^7.4.5",
"league/uri": "^7.6",
"league/uri-components": "^7.6",
"symfony/process": "^7.4.5 || ^8.0.5"
},
"require-dev": {
Expand Down
4,143 changes: 3,068 additions & 1,075 deletions composer.lock

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions src/CloudServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace craft\cloud;

use craft\cloud\transforms\AssetTransforms;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;

Expand All @@ -25,6 +26,11 @@ public function register(): void
$this->configureLogging();
}

public function boot(): void
{
AssetTransforms::register($this->app);
}

private function configureLogging(): void
{
Config::set('logging.default', 'stderr');
Expand Down
61 changes: 61 additions & 0 deletions src/signing/RequestSigner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace craft\cloud\signing;

use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use HttpMessageSignatures\Algorithm\HmacSha256;
use HttpMessageSignatures\Signer;
use Psr\Http\Message\RequestInterface;
use RuntimeException;

/**
* Signs outgoing HTTP requests for Craft Cloud with HTTP Message Signatures.
*/
class RequestSigner
{
private const array DEFAULT_SIGNATURE_COMPONENTS = ['@method', '@target-uri'];
private const int DEFAULT_EXPIRES_AFTER = 300;
private const string DEFAULT_SIGNATURE_ID = 'sig';
private const string DEFAULT_KEY_ID = 'hmac';

/**
* @param list<string> $components
*/
public function __construct(
#[\SensitiveParameter]
private readonly string $signingKey,
private readonly array $components = self::DEFAULT_SIGNATURE_COMPONENTS,
private readonly int $expiresAfter = self::DEFAULT_EXPIRES_AFTER,
private readonly string $signatureId = self::DEFAULT_SIGNATURE_ID,
private readonly string $keyId = self::DEFAULT_KEY_ID,
) {}

public function sign(RequestInterface $request): RequestInterface
{
$created = time();
$signedRequest = new Signer(new HmacSha256($this->signingKey))->sign($request, $this->components, [
'signatureId' => $this->signatureId,
'keyid' => $this->keyId,
'created' => $created,
'expires' => $created + $this->expiresAfter,
'tag' => 'craft-cloud',
]);

if (!$signedRequest instanceof RequestInterface) {
throw new RuntimeException('Request signatures must produce a signed request.');
}

return $signedRequest;
}

public function createHandlerStack(?HandlerStack $handlerStack = null): HandlerStack
{
$handlerStack ??= HandlerStack::create();
$handlerStack->push(Middleware::mapRequest($this->sign(...)), 'craft_cloud_request_signing');

return $handlerStack;
}
}
54 changes: 54 additions & 0 deletions src/transforms/AssetCdn.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace craft\cloud\transforms;

use CraftCms\Cms\Asset\AssetsHelper;
use CraftCms\Cms\Asset\Elements\Asset;
use CraftCms\Cms\Cms;
use GuzzleHttp\Psr7\HttpFactory;
use HttpMessageSignatures\Algorithm\HmacSha256;
use HttpMessageSignatures\Url\UrlSigner;
use HttpMessageSignatures\Url\UrlSigningConfig;
use League\Uri\Components\Query;
use League\Uri\Modifier;

/**
* Signed transform URLs on Craft Cloud’s asset CDN, which its gateway verifies before transforming.
*/
class AssetCdn
{
public function __construct(
#[\SensitiveParameter]
private readonly string $signingKey,
) {}

/**
* The asset’s URL with Cloudflare image options in its query, signed over `@path` and `@query` in the `s`
* parameter. The gateway leaves `v` out of the signature, so the asset’s revision is added afterward, like on
* other asset URLs.
*
* @param array<string, mixed> $options
*/
public function transformUrl(Asset $asset, array $options): string
{
$url = Modifier::wrap(AssetsHelper::generateUrl($asset))
->removeQueryParameters('v')
->mergeQuery(Query::fromVariable($options))
->removeEmptyQueryPairs()
->toString();

$url = new UrlSigner(
algorithm: new HmacSha256($this->signingKey),
requestFactory: new HttpFactory(),
config: new UrlSigningConfig(components: ['@path', '@query'], signatureParam: 's'),
)->sign($url);

if (!Cms::config()->revAssetUrls) {
return $url;
}

return Modifier::wrap($url)->appendQueryParameters(AssetsHelper::revParams($asset))->toString();
}
}
37 changes: 37 additions & 0 deletions src/transforms/AssetTransforms.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace craft\cloud\transforms;

use CraftCms\Cms\Asset\AssetTransformers;
use Illuminate\Contracts\Container\Container;

/**
* Registers the `craft-cloud` asset transform driver.
*/
class AssetTransforms
{
/**
* Registered in every environment, like the `craft-cloud` disk driver, so the same disk config works anywhere.
*/
public static function register(Container $app): void
{
$extend = static function (AssetTransformers $transformers): void {
$transformers->extend(
CloudAssetTransformDriver::DRIVER,
static fn(Container $app): CloudAssetTransformDriver => new CloudAssetTransformDriver(
$app->make(AssetTransformers::class),
new AssetCdn($_SERVER['CRAFT_CLOUD_SIGNING_KEY'] ?? ''),
),
);
};

// Like ServiceProvider::callAfterResolving(), in case something resolved it early
$app->afterResolving(AssetTransformers::class, $extend);

if ($app->resolved(AssetTransformers::class)) {
$extend($app->make(AssetTransformers::class));
}
}
}
123 changes: 123 additions & 0 deletions src/transforms/CloudAssetTransformDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

declare(strict_types=1);

namespace craft\cloud\transforms;

use CraftCms\Cms\Asset\AssetTransformers;
use CraftCms\Cms\Asset\Contracts\AssetTransformDriver;
use CraftCms\Cms\Asset\Data\AssetTransformDriverDefinition;
use CraftCms\Cms\Asset\Data\AssetTransformer;
use CraftCms\Cms\Asset\Data\AssetTransformRequest;
use CraftCms\Cms\Asset\Data\AssetTransformResult;
use CraftCms\Cms\Asset\Elements\Asset;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Image\Data\ImageTransform;
use CraftCms\Cms\Image\ImageHelper;
use CraftCms\Cms\Shared\Exceptions\NotSupportedException;
use CraftCms\Cms\Support\File;
use Illuminate\Filesystem\AwsS3V3Adapter;
use Illuminate\Support\Arr;

/**
* Transforms images and PDFs with signed Cloudflare image URLs on Craft Cloud’s asset CDN.
*
* Projects choose it per disk:
*
* ```php
* 'images' => ['driver' => 'craft-cloud', 'prefix' => 'images', 'asset_transformer' => 'craft-cloud'],
* ```
*
* It transforms every asset on the disks that choose it. Assets on disks that aren’t S3, which the CDN can’t serve,
* go to the `craft` driver.
*/
class CloudAssetTransformDriver implements AssetTransformDriver
{
public const string DRIVER = 'craft-cloud';

/** Source extensions the gateway transforms. */
private const array EXTENSIONS = ['avif', 'gif', 'heic', 'heif', 'jpeg', 'jpg', 'pdf', 'png', 'svg', 'webp'];

public function __construct(
private readonly AssetTransformers $transformers,
private readonly AssetCdn $cdn,
) {}

public function definition(): AssetTransformDriverDefinition
{
return new AssetTransformDriverDefinition(parameterRules: CloudflareOptions::rules());
}

public function transform(AssetTransformRequest $request): AssetTransformResult
{
$asset = $request->asset;
$parameters = $request->parameters + ['upscale' => Cms::config()->upscaleImages];

// Core can’t hand a transform to another driver, so call the `craft` driver directly.
if (!$asset->getVolume()->sourceDisk() instanceof AwsS3V3Adapter) {
return $this->transformers
->driver('craft')
->transform(new AssetTransformRequest(
asset: $asset,
transformer: new AssetTransformer('craft'),
parameters: Arr::only($request->parameters, ImageTransform::CORE_PARAMETERS),
));
}

$extension = $this->supportedExtension($asset);
$options = CloudflareOptions::fromParameters(
$parameters,
$asset->getHasFocalPoint() ? $asset->getFocalPoint() : null,
);

// The gateway only rasterizes PDFs when a format is requested. `auto` lets it pick AVIF or WebP.
if ($extension === 'pdf') {
$options['format'] ??= 'auto';
}

$format = $parameters['format'] ?? ($extension === 'pdf' ? 'png' : $extension);
[$width, $height] = $this->outputDimensions($asset, $parameters);

return new AssetTransformResult(
url: $this->cdn->transformUrl($asset, $options),
mimeType: File::getMimeTypeByExtension("transform.{$format}") ?? 'application/octet-stream',
width: $width,
height: $height,
);
}

private function supportedExtension(Asset $asset): string
{
$extension = strtolower($asset->getExtension());
$disabled = ['gif' => !Cms::config()->transformGifs, 'svg' => !Cms::config()->transformSvgs];

if (!in_array($extension, self::EXTENSIONS, strict: true) || ($disabled[$extension] ?? false)) {
throw new NotSupportedException("Craft Cloud doesn’t transform .{$extension} files.");
}

return $extension;
}

/**
* @param array<string, mixed> $parameters
* @return array{?int, ?int}
*/
private function outputDimensions(Asset $asset, array $parameters): array
{
$sourceWidth = $asset->getWidth();
$sourceHeight = $asset->getHeight();

if (!$sourceWidth || !$sourceHeight) {
return [null, null];
}

return ImageHelper::targetDimensions(
$sourceWidth,
$sourceHeight,
filter_var($parameters['width'] ?? null, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE),
filter_var($parameters['height'] ?? null, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE),
$parameters['mode'] ?? 'crop',
filter_var($parameters['upscale'], FILTER_VALIDATE_BOOL),
);
}
}
Loading
Loading