From fa3c06e599d99a551abd5b0e04be56b4091a0c88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20REYNAUD?= Date: Thu, 9 Apr 2026 14:34:24 +0200 Subject: [PATCH] feat: implement CDN-PHP new features --- .gitignore | 1 + CLAUDE.md | 60 +++++++++++++++++++ README.md | 11 +++- src/CdnPhpBundle.php | 4 ++ .../InterventionImageFallbackHandler.php | 21 +++++-- src/Proxy.php | 14 ++++- src/Signer.php | 26 ++++++++ 7 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 7fd0d1c..153df72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.cache/ +/.claude/settings.local.json /vendor/ composer.lock diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..60496f7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +make install # Install Docker stack + Composer dependencies +make check # Run all checks (lint + analyse + security) +make lint # PHP Code Sniffer (PHPCS) — code style check +make fixer # Auto-fix code style with PHPCBF +make analyse # PHPStan static analysis (level 8) +make security # Audit dependencies for CVEs +make shell # Connect to PHP container +``` + +There are no unit tests in this project — quality is enforced through static analysis (PHPStan level 8) and code style (PHPCS). + +## Architecture + +This is a Symfony Bundle that proxies image requests through [CDN PHP](https://github.com/babeuloula/cdn-php), with optional local fallback processing via Intervention Image. + +**Request flow:** +1. A Twig template calls `{{ cdn_php('image.jpg', {w: 200, h: 200}) }}` +2. `ProxyExtension` builds an `Options` value object, optionally signs it via `Signer`, and generates a URL pointing to a configured Symfony route +3. The application's controller receives the request and calls `Proxy::response()` +4. `Proxy` optionally checks the local file exists (`check_assets`), then fetches from the remote CDN PHP service via `HttpClientInterface` +5. If the CDN PHP call fails, `FallbackHandlerInterface` (default: `InterventionImageFallbackHandler`) processes the image locally and caches it for 14 days +6. Response headers (cache-control, etag, content-type, etc.) are copied to the returned `Response` + +**Key classes:** + +| Class | Role | +|------------------------------------|-------------------------------------------------------------------------------------------------------------| +| `CdnPhpBundle` | Bundle entry point; defines configuration tree and loads services | +| `Proxy` | Core service; orchestrates CDN fetch + fallback | +| `AbstractHandler` | Base class; normalizes asset paths, parses request headers | +| `Options` | Value object for image transformation parameters (w, h, watermark, signature) | +| `Signer` | Dual signing: SHA1 (app→browser, paramètre `signature`) + HMAC-SHA256 (app→CDN, paramètres `expires`+`sig`) | +| `InterventionImageFallbackHandler` | Local Intervention Image v3 processing when CDN is unavailable | +| `ProxyExtension` | Twig functions `cdn_php()` and `cdn()` | + +**Configuration (config/packages/cdn_php.yaml):** +```yaml +cdn_php: + proxy: + assets_path: '/path/to/assets' # Local assets directory (required) + url: 'https://cdn.example.com' # CDN PHP service URL (required) + check_assets: true # Validate local file before CDN request + encrypted_parameters: false # Enable HMAC query parameter signing + encrypter: + secret_key: null # Signs Twig-generated URLs (app→browser). Required when encrypted_parameters: true + cdn_secret_key: null # Signs Proxy requests to CDN PHP (app→CDN). Must match CDN's SIGNATURE_SECRET + cdn_expires_ttl: 3600 # Validity of CDN signatures in seconds (default: 1 hour) + twig: + route_name: 'app_cdn' # Symfony route to the proxy controller (required) + route_parameter: 'path' # Route parameter name for the file path (required) +``` + +**Supported PHP:** >=8.1 | **Supported Symfony:** 6, 7, 8 diff --git a/README.md b/README.md index 73d17e7..8c6d940 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,16 @@ cdn_php: check_assets: true # if the bundle needs to check if you have the file on the server before fetch from CDN PHP encrypted_parameters: false # if you need to hide the query parameters on your application encrypter: - secret_key: null # the key encrypting and decrypting the query parameters (required if proxy.encrypted_parameters is true) + secret_key: null # Signs URLs generated by cdn_php() / cdn() Twig functions (app → browser). + # Used to prevent tampering with query parameters on the public-facing proxy route. + # Algorithm: SHA1(query_string + secret_key). Validated by Signer::isValid(). + # Required when proxy.encrypted_parameters is true. + cdn_secret_key: null # Signs requests sent by Proxy to the CDN PHP service (app → CDN). + # Must match the SIGNATURE_SECRET environment variable set on the CDN PHP instance. + # Algorithm: HMAC-SHA256(imageUrl:expires, cdn_secret_key). + # Leave null if CDN PHP runs without SIGNATURE_SECRET. + cdn_expires_ttl: 3600 # Validity duration (in seconds) of CDN request signatures. + # Only used when cdn_secret_key is set. Default: 3600 (1 hour). twig: route_name: 'mandatory' # the route to the controller that displays the assets route_parameter: 'mandatory' # the route parameter name diff --git a/src/CdnPhpBundle.php b/src/CdnPhpBundle.php index d6387b8..f2f957c 100644 --- a/src/CdnPhpBundle.php +++ b/src/CdnPhpBundle.php @@ -40,6 +40,8 @@ public function configure(DefinitionConfigurator $definition): void ->arrayNode('encrypter')->addDefaultsIfNotSet() ->children() ->scalarNode('secret_key')->defaultNull()->end() + ->scalarNode('cdn_secret_key')->defaultNull()->end() + ->integerNode('cdn_expires_ttl')->defaultValue(3600)->end() ->end() ->end() // encrypter ->arrayNode('twig')->isRequired() @@ -70,6 +72,8 @@ public function loadExtension(array $config, ContainerConfigurator $container, C ->get(Signer::class) ->public() ->arg('$secretKey', $config['encrypter']['secret_key']) + ->arg('$cdnSecretKey', $config['encrypter']['cdn_secret_key']) + ->arg('$cdnExpiresTtl', $config['encrypter']['cdn_expires_ttl']) ; $container->services() diff --git a/src/FallbackHandler/InterventionImageFallbackHandler.php b/src/FallbackHandler/InterventionImageFallbackHandler.php index 31c9ddb..ef95f3a 100644 --- a/src/FallbackHandler/InterventionImageFallbackHandler.php +++ b/src/FallbackHandler/InterventionImageFallbackHandler.php @@ -65,10 +65,11 @@ function (ItemInterface $item) use ($options, $image, $headers): array { $item->expiresAfter($this->cacheLifetime); - $encodedImage = (true === $this->supportWebp($headers)) - ? $image->toWebp() - : $image->encodeByPath() - ; + $encodedImage = match (true) { + $this->supportsAvif($headers) => $image->toAvif(), + $this->supportsWebp($headers) => $image->toWebp(), + default => $image->encodeByPath(), + }; return [ 'content' => $encodedImage->toString(), @@ -88,7 +89,17 @@ function (ItemInterface $item) use ($options, $image, $headers): array { } /** @param array $headers */ - private function supportWebp(array $headers): bool + private function supportsAvif(array $headers): bool + { + if (false === \array_key_exists('accept', $headers)) { + return false; + } + + return str_contains($headers['accept'], 'image/avif'); + } + + /** @param array $headers */ + private function supportsWebp(array $headers): bool { if (false === \array_key_exists('accept', $headers)) { return false; diff --git a/src/Proxy.php b/src/Proxy.php index 741a484..e9d4232 100644 --- a/src/Proxy.php +++ b/src/Proxy.php @@ -31,6 +31,7 @@ public function __construct( private readonly Filesystem $filesystem, private readonly HttpClientInterface $client, private readonly string $cdnPhpUrl, + private readonly Signer $signer, private readonly ?FallbackHandlerInterface $fallbackHandler = null, ) { parent::__construct($assetsPath); @@ -48,9 +49,17 @@ public function response(string $file, ?Options $options = null, array $headers $newResponse = new Response(); try { + $queryParts = $options?->buildQuery(false) ?? ''; + + if (true === $this->signer->isCdnSigningEnabled()) { + $cdnSignature = $this->signer->signCdnRequest($file); + $signatureQuery = http_build_query($cdnSignature); + $queryParts = '' !== $queryParts ? $queryParts . '&' . $signatureQuery : $signatureQuery; + } + $response = $this->client->request( Request::METHOD_GET, - $this->cdnPhpUrl . $file . '?' . ($options?->buildQuery(false) ?? ''), + $this->cdnPhpUrl . $file . '?' . $queryParts, [ 'headers' => $headers, 'timeout' => 25, @@ -67,6 +76,9 @@ public function response(string $file, ?Options $options = null, array $headers 'content-encoding', 'content-type', 'content-length', + 'vary', + 'x-content-type-options', + 'x-dominant-color', ]; foreach ($copiedHeaders as $header) { diff --git a/src/Signer.php b/src/Signer.php index c7db0ee..7eb3d9b 100644 --- a/src/Signer.php +++ b/src/Signer.php @@ -19,6 +19,8 @@ final class Signer { public function __construct( private readonly ?string $secretKey = null, + private readonly ?string $cdnSecretKey = null, + private readonly int $cdnExpiresTtl = 3600, ) { } @@ -46,6 +48,30 @@ public function calcSignature(Options $options): string return sha1($options->buildQuery(false) . $this->getSecretKey()); } + public function isCdnSigningEnabled(): bool + { + return \strlen($this->cdnSecretKey ?? '') > 0; + } + + /** + * Generates expires + sig parameters for a CDN PHP request. + * Replicates the URL normalization of UriDecoder::getUri() so the + * computed imageUrl matches what the CDN will use for verification. + * + * @return array{expires: int, sig: string} + */ + public function signCdnRequest(string $file): array + { + $path = ltrim($file, '/'); + $path = str_replace(['www.', 'http://', 'http:/', 'https://', 'https:/'], '', $path); + $imageUrl = 'https://' . $path; + + $expires = time() + $this->cdnExpiresTtl; + $sig = hash_hmac('sha256', $imageUrl . ':' . $expires, (string) $this->cdnSecretKey); + + return ['expires' => $expires, 'sig' => $sig]; + } + private function getSecretKey(): string { return $this->secretKey ?? '';