diff --git a/CLAUDE.md b/CLAUDE.md index e1f17c4..8686c72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ make deploy-prod # Deploy to production on AWS **Request flow:** 1. `Cdn` validates the request (GET only, domain in `ALLOWED_DOMAINS`) 2. `UriDecoder` parses the source URL and query parameters into a `QueryParams` DTO -3. `PathProcessor` generates a deterministic cache path from the params +3. `PathProcessor` generates a deterministic cache path: images → `{domain}/{params}/{hash}.{ext}`, static assets → `{domain}/static/{hash}.{ext}` (resize params ignored). The `v` param is folded into the MD5 hash for both types 4. If the cached file doesn't exist, `Storage` fetches the original via `UrlFilesystemAdapter` (Flysystem adapter wrapping Symfony's HTTP client) 5. For **images**: `ImageProcessor` applies transformations (Imagick): resize, compression, WebP conversion, watermark 6. For **static assets** (CSS/JS/fonts/SVG/ICO): `StaticAssetProcessor` minifies CSS and JS; other types are served as-is @@ -53,7 +53,7 @@ make deploy-prod # Deploy to production on AWS - `src/Processor/PathProcessor.php` - cache key generation - `src/Decoder/UriDecoder.php` - URL and query param parsing - `src/Cache/Cache.php` - HTTP response and cache headers -- `src/Dto/QueryParams.php` - immutable DTO for image transformation parameters +- `src/Dto/QueryParams.php` - immutable DTO for request parameters: image transformations (`w`, `h`, watermark) and cache versioning (`v`) **Exception-driven validation:** Domain/URI/extension/file errors are thrown as typed exceptions (`NotAllowedDomain`, `InvalidUri`, `FileNotFound`, etc.) and caught in `Cdn` to return appropriate HTTP responses. @@ -84,6 +84,18 @@ The app is configured entirely via environment variables (see `.env`): | `CACHE_TTL` | HTTP cache TTL in seconds (default: 1 year) | | `LOG_LEVEL` | PSR-3 log level | +**Query parameters (per request):** + +| Parameter | Purpose | +|-----------|-------------------------------------------------------------------------| +| `w` | Target width (images only, max 5000) | +| `h` | Target height (images only, max 5000) | +| `wu` | Watermark URL (images only, must be an allowed domain) | +| `wp` | Watermark position (default: `center`) | +| `ws` | Watermark size % (default: 75) | +| `wo` | Watermark opacity % (default: 50) | +| `v` | Cache version — changes the MD5 hash without altering the source URL | + In production, secrets are pulled from AWS SSM Parameter Store (see `serverless.yml`). Local dev uses MinIO (S3-compatible) on port 9001. ## Deployment diff --git a/README.md b/README.md index 408261c..7a2506f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ It supports fetching, optimizing, caching, and serving images and static assets - `wp` (watermark position, default: center) - `ws` (watermark size percentage, default: 75%) - `wo` (watermark opacity percentage, default: 50%) -- **Smart Storage Structure:** Assets are stored based on query parameters for deterministic cache keys. +- **Cache Versioning:** Add `?v=` to any URL (image or static asset) to generate a new cache key without changing the source URL. The version is folded into the MD5 hash — no extra folder is created. +- **Smart Storage Structure:** Images are stored under `{domain}/{params}/{hash}.{ext}`; static assets under `{domain}/static/{hash}.{ext}`. Resize parameters are ignored for static assets. - **Serverless Compatible:** Optimized to run in a serverless environment. ## Serverless @@ -155,6 +156,12 @@ You can fetch an optimized image by calling: https://cdn-php.loc/https://www.mysite.com/image.png?w=200&h=200 ``` +Add `?v=` to bust the cache without changing the source URL: + +``` +https://cdn-php.loc/https://www.mysite.com/image.png?w=200&h=200&v=2 +``` + The CDN will: - Fetch the image from www.mysite.com - Strip EXIF metadata @@ -172,6 +179,9 @@ You can also use the CDN to serve and optimize your static assets: # CSS (automatically minified) https://cdn-php.loc/https://www.mysite.com/style.css +# With cache versioning +https://cdn-php.loc/https://www.mysite.com/style.css?v=2 + # JavaScript (automatically minified) https://cdn-php.loc/https://www.mysite.com/app.js diff --git a/src/Cdn.php b/src/Cdn.php index e0a6e1e..4691d82 100644 --- a/src/Cdn.php +++ b/src/Cdn.php @@ -84,7 +84,7 @@ public function handleRequest(Request $request): Response $extension = mb_strtolower(pathinfo($decoder->getImageUrl(), PATHINFO_EXTENSION)); $isImage = \in_array($extension, self::IMAGE_EXTENSIONS, true); - $pathProcessor = new PathProcessor($decoder); + $pathProcessor = new PathProcessor($decoder, $isImage); [$supportAvif, $supportWebp] = $this->detectOutputFormat($request, $isImage); diff --git a/src/Dto/QueryParams.php b/src/Dto/QueryParams.php index 70c536c..9bc5d3e 100644 --- a/src/Dto/QueryParams.php +++ b/src/Dto/QueryParams.php @@ -23,6 +23,7 @@ final class QueryParams public const string PARAM_WATERMARK_POSITION = 'wp'; public const string PARAM_WATERMARK_SIZE = 'ws'; public const string PARAM_WATERMARK_OPACITY = 'wo'; + public const string PARAM_VERSION = 'v'; public const int MAX_DIMENSION = 5000; public readonly ?string $watermarkUrl; @@ -36,6 +37,7 @@ private function __construct( public readonly WatermarkPosition $watermarkPosition, int $watermarkSize, int $watermarkOpacity, + public readonly ?string $version = null, ) { $this->watermarkUrl = (true === str_contains((string) $watermarkUrl, '://')) ? (explode('://', (string) $watermarkUrl)[1] ?? null) @@ -73,6 +75,7 @@ public static function fromArray(array $query): QueryParams empty($query[self::PARAM_WATERMARK_POSITION]) ? WatermarkPosition::default() : (WatermarkPosition::tryFrom($query[self::PARAM_WATERMARK_POSITION]) ?? WatermarkPosition::default()), empty($query[self::PARAM_WATERMARK_SIZE]) ? 75 : ((int) $query[self::PARAM_WATERMARK_SIZE]), empty($query[self::PARAM_WATERMARK_OPACITY]) ? 50 : ((int) $query[self::PARAM_WATERMARK_OPACITY]), + empty($query[self::PARAM_VERSION]) ? null : ((string) $query[self::PARAM_VERSION]), ); // phpcs:enable } diff --git a/src/Processor/PathProcessor.php b/src/Processor/PathProcessor.php index 73e69b4..05ae021 100644 --- a/src/Processor/PathProcessor.php +++ b/src/Processor/PathProcessor.php @@ -31,8 +31,10 @@ final class PathProcessor private string $path; - public function __construct(private readonly UriDecoder $decoder) - { + public function __construct( + private readonly UriDecoder $decoder, + private readonly bool $isImage = true, + ) { $this->generatePath(); } @@ -51,6 +53,17 @@ public function getPath(bool $supportAvif = false, bool $supportWebp = false): s private function generatePath(): void { + if (false === $this->isImage) { + $extension = strtolower(pathinfo($this->decoder->getImageUrl(), PATHINFO_EXTENSION)); + $this->path = sprintf( + '%s/static/%s', + $this->decoder->getDomain(), + $this->buildFilename($this->decoder->getImageUrl(), $extension), + ); + + return; + } + $params = $this->decoder->getParams()->toArray(); unset($params['fit'], $params['markpad']); @@ -68,13 +81,20 @@ private function generatePath(): void $sourceExtension = strtolower(pathinfo($this->decoder->getImageUrl(), PATHINFO_EXTENSION)); $extension = self::TRANSCODED_EXTENSIONS[$sourceExtension] ?? $sourceExtension; - $filename = md5($this->decoder->getImageUrl()) . '.' . $extension; $this->path = sprintf( '%s%s/%s', $this->decoder->getDomain(), ('' === $path) ? '' : "/$path", - $filename, + $this->buildFilename($this->decoder->getImageUrl(), $extension), ); } + + private function buildFilename(string $baseUrl, string $extension): string + { + $version = $this->decoder->getParams()->version; + $hashInput = (true === \is_string($version)) ? "{$baseUrl}:v={$version}" : $baseUrl; + + return md5($hashInput) . '.' . $extension; + } } diff --git a/tests/Processor/PathProcessorTest.php b/tests/Processor/PathProcessorTest.php index 9724437..10241cf 100644 --- a/tests/Processor/PathProcessorTest.php +++ b/tests/Processor/PathProcessorTest.php @@ -127,4 +127,90 @@ public function canGetPathWithDifferentExtensions(): void ); } } + + #[Test] + public function staticAssetPathIsUnderDomainStaticFolder(): void + { + $decoder = new UriDecoder(static::TEST_CSS_URI); + $pathProcessor = new PathProcessor($decoder, false); + + static::assertSame( + static::TEST_DOMAIN . '/static/' . static::TEST_CSS_FILENAME_MD5, + $pathProcessor->getPath(), + ); + } + + #[Test] + public function staticAssetPathIgnoresResizeQueryParams(): void + { + $decoder = new UriDecoder(static::TEST_CSS_URI . '?w=300&h=200'); + $pathProcessor = new PathProcessor($decoder, false); + + static::assertSame( + static::TEST_DOMAIN . '/static/' . static::TEST_CSS_FILENAME_MD5, + $pathProcessor->getPath(), + ); + } + + #[DataProvider('staticExtensionsProvider')] + #[Test] + public function staticAssetPathPreservesExtension(string $uri, string $expectedFilename): void + { + $decoder = new UriDecoder($uri); + $pathProcessor = new PathProcessor($decoder, false); + + static::assertSame( + static::TEST_DOMAIN . '/static/' . $expectedFilename, + $pathProcessor->getPath(), + ); + } + + public static function staticExtensionsProvider(): \Generator + { + yield [static::TEST_CSS_URI, static::TEST_CSS_FILENAME_MD5]; + yield [static::TEST_JS_URI, static::TEST_JS_FILENAME_MD5]; + yield [static::TEST_WOFF2_URI, static::TEST_WOFF2_FILENAME_MD5]; + } + + #[Test] + public function imagePathWithVersionProducesDifferentHash(): void + { + $decoderWithVersion = new UriDecoder(static::TEST_BASE_URI . '?v=2'); + $pathWithVersion = (new PathProcessor($decoderWithVersion))->getPath(); + + $decoderWithout = new UriDecoder(static::TEST_BASE_URI); + $pathWithout = (new PathProcessor($decoderWithout))->getPath(); + + static::assertNotSame($pathWithVersion, $pathWithout); + static::assertStringStartsWith(static::TEST_DOMAIN . '/w0/', $pathWithVersion); + static::assertStringEndsWith('1de6bb65981e48d8780955906993fe95.jpg', $pathWithVersion); + } + + #[Test] + public function staticAssetWithVersionProducesDifferentHash(): void + { + $decoderWithVersion = new UriDecoder(static::TEST_CSS_URI . '?v=2'); + $pathWithVersion = (new PathProcessor($decoderWithVersion, false))->getPath(); + + $decoderWithout = new UriDecoder(static::TEST_CSS_URI); + $pathWithout = (new PathProcessor($decoderWithout, false))->getPath(); + + static::assertNotSame($pathWithVersion, $pathWithout); + static::assertSame( + static::TEST_DOMAIN . '/static/f9f2413e520d0357110782f27b274a73.css', + $pathWithVersion, + ); + } + + #[Test] + public function staticAssetWithVersionIgnoresResizeParams(): void + { + $decoderWithBoth = new UriDecoder(static::TEST_CSS_URI . '?w=300&v=2'); + $path = (new PathProcessor($decoderWithBoth, false))->getPath(); + + static::assertSame( + static::TEST_DOMAIN . '/static/f9f2413e520d0357110782f27b274a73.css', + $path, + ); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index c24e4ca..f961d82 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -56,6 +56,9 @@ class TestCase extends BaseTestCase protected const string TEST_HEIC_URI = 'https://example.com/photo.heic'; protected const string TEST_WASM_URI = 'https://example.com/module.wasm'; protected const string TEST_CORRUPT_JSON_URI = 'https://example.com/broken.json'; + protected const string TEST_CSS_FILENAME_MD5 = '8d1c8eb971036ec7056d3c6bb3208a15.css'; + protected const string TEST_JS_FILENAME_MD5 = 'd3d3f07723bf47b40e1caf2816efb7d0.js'; + protected const string TEST_WOFF2_FILENAME_MD5 = 'f96b2d01fa97b989c8c92aa04536b523.woff2'; private Container $container;