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
16 changes: 14 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<value>` 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
Expand Down Expand Up @@ -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=<value>` 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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/Cdn.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
3 changes: 3 additions & 0 deletions src/Dto/QueryParams.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
28 changes: 24 additions & 4 deletions src/Processor/PathProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand All @@ -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']);

Expand All @@ -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;
}
}
86 changes: 86 additions & 0 deletions tests/Processor/PathProcessorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
3 changes: 3 additions & 0 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading