Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/.cache/
/.claude/settings.local.json
/vendor/

composer.lock
60 changes: 60 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/CdnPhpBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
/** @var ArrayNodeDefinition $treeBuilder */
$treeBuilder = $definition->rootNode();

$treeBuilder

Check failure on line 30 in src/CdnPhpBundle.php

View workflow job for this annotation

GitHub Actions / analyse

Call to method arrayNode() on an unknown class Symfony\Component\Config\Definition\Builder\NodeBuilder<Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition>.
->children()
->arrayNode('proxy')->isRequired()
->children()
Expand All @@ -40,6 +40,8 @@
->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()
Expand Down Expand Up @@ -70,6 +72,8 @@
->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()
Expand Down
21 changes: 16 additions & 5 deletions src/FallbackHandler/InterventionImageFallbackHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -88,7 +89,17 @@ function (ItemInterface $item) use ($options, $image, $headers): array {
}

/** @param array<string, mixed> $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<string, mixed> $headers */
private function supportsWebp(array $headers): bool
{
if (false === \array_key_exists('accept', $headers)) {
return false;
Expand Down
14 changes: 13 additions & 1 deletion src/Proxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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) {
Expand Down
26 changes: 26 additions & 0 deletions src/Signer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}

Expand Down Expand Up @@ -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 ?? '';
Expand Down
Loading