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
27 changes: 15 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,24 @@ 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 image source URL and query parameters into a `QueryParams` DTO
2. `UriDecoder` parses the source URL and query parameters into a `QueryParams` DTO
3. `PathProcessor` generates a deterministic cache path from the params
4. If the cached file doesn't exist, `Storage` fetches the original via `UrlFilesystemAdapter` (Flysystem adapter wrapping Symfony's HTTP client)
5. `ImageProcessor` applies transformations (Imagick): resize, compression, WebP conversion, watermark
6. Result is saved to storage (local filesystem or S3 via Flysystem)
7. `Cache` returns a Symfony `Response` with `Cache-Control`, `ETag`, and `Last-Modified` headers
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
7. Result is saved to storage (local filesystem or S3 via Flysystem)
8. `Cache` returns a Symfony `Response` with `Cache-Control`, `ETag`, and `Last-Modified` headers

**Key classes:**
- `src/Cdn.php` — orchestrates the full request lifecycle
- `src/Container.php` — custom DI container; wires all services from env vars
- `src/Storage/Storage.php` — Flysystem abstraction (local or S3)
- `src/Processor/ImageProcessor.php` — ImageMagick transformations
- `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/Cdn.php` - orchestrates the full request lifecycle; routes to image or static asset processing
- `src/Container.php` - custom DI container; wires all services from env vars
- `src/Storage/Storage.php` - Flysystem abstraction (local or S3)
- `src/Processor/ImageProcessor.php` - ImageMagick transformations
- `src/Processor/StaticAssetProcessor.php` - CSS/JS minification (matthiasmullie/minify); font/SVG/ICO passthrough
- `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

**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 @@ -86,4 +88,5 @@ Deployed to AWS Lambda (PHP 8.4 FPM, Bref framework) with:
- 2048 MB memory, 28-second timeout
- Imagick Lambda layer
- Warm-up ping every 5 minutes via EventBridge
- GZIP compression via API Gateway `minimumCompressionSize: 1024` (responses > 1 KB compressed automatically)
- CI/CD via GitHub Actions (`.github/workflows/`)
48 changes: 39 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ It supports fetching, optimizing, caching and serving images dynamically while e
- **On-the-Fly Image Processing:** Fetches images from a URL, compresses them lossless, and caches them.
- **WebP Support:** Converts images to WebP format if supported by the requesting client (GIFs are always served as GIF to preserve animation).
- **Animated GIF Support:** Preserves all frames of animated GIFs through resize operations.
- **Static Asset Support:** Fetches, optimizes, and serves JS/CSS/font files with proper cache headers:
- **CSS & JS minification:** Automatically minifies `.css` and `.js` files to reduce file size.
- **Font passthrough:** Serves `.woff`, `.woff2`, `.ttf`, `.eot`, `.otf` with long-term caching.
- **SVG & ICO passthrough:** Serves `.svg` and `.ico` with long-term caching.
- **Configurable Storage:** Supports both local filesystem and S3-compatible storage.
- **Dynamic Image Resizing:** Resize images via query parameters:
- `w` (width)
Expand All @@ -19,7 +23,7 @@ It supports fetching, optimizing, caching and serving images dynamically while e
- `wp` (watermark position, default: center)
- `ws` (watermark size percentage, default: 75%)
- `wo` (watermark opacity percentage, default: 50%)
- **Smart Storage Structure:** Images are stored based on query parameters.
- **Smart Storage Structure:** Assets are stored based on query parameters.
- **Serverless Compatible:** Optimized to run in a serverless environment.
- **SSRF Protection:** Only domains listed in `ALLOWED_DOMAINS` can be fetched (applies to both source images and watermarks).
- **Fetch Hardening:** Configurable timeout, maximum file size, and redirect policy to prevent slow-loris, image-bomb, and SSRF-via-redirect attacks.
Expand Down Expand Up @@ -129,6 +133,8 @@ https://cdn-php.loc

## Usage

### Images

You can fetch an optimized image by calling:

```
Expand All @@ -142,6 +148,38 @@ The CDN will:
- Store it based on parameters
- Serve it with proper caching headers (`Cache-Control`, `ETag`, `Vary: Accept`)

### Static Assets

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

# JavaScript (automatically minified)
https://cdn-php.loc/https://www.mysite.com/app.js

# Fonts (served as-is with long-term caching)
https://cdn-php.loc/https://www.mysite.com/font.woff2

# SVG / ICO (served as-is with long-term caching)
https://cdn-php.loc/https://www.mysite.com/logo.svg
```

Supported extensions: `css`, `js`, `woff`, `woff2`, `ttf`, `eot`, `otf`, `svg`, `ico`, `xml`

CSS and JS files are automatically minified (comments and unnecessary whitespace removed) before being cached, reducing their size for faster delivery.

### Compression (GZIP)

**With the serverless setup (Bref / API Gateway):** GZIP compression is handled automatically by API Gateway via the `minimumCompressionSize` setting in `serverless.yml`. Responses larger than 1 KB are compressed transparently based on the client's `Accept-Encoding` header — no application-level changes needed.

**Without serverless (Docker, Nginx, Apache…):** The CDN itself does not add `Content-Encoding: gzip` headers. You must enable compression at the web server or reverse-proxy level:

- **Nginx:** `gzip on; gzip_types text/css application/javascript font/woff2 image/svg+xml;`
- **Apache:** enable `mod_deflate` with the equivalent `AddOutputFilterByType` directive
- **Caddy:** compression is enabled by default

### Force re-fetch

To bypass the cache and re-fetch the source image:
Expand Down Expand Up @@ -181,14 +219,6 @@ make test-security
make test
```

## Future works

- [ ] Create a Dockerfile for serverless functions
- [ ] Add a CLI with [Silly](https://github.com/mnapoli/silly)
- [ ] Write a command to clear CDN cache folder
- [ ] Write a command to clear all CDN images
- [ ] Write a command to display some stats

## License

This project is open-source and available under the MIT License.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"league/flysystem": "^3.29",
"league/flysystem-aws-s3-v3": "^3.0",
"league/glide": "^2.3",
"matthiasmullie/minify": "^1.3",
"symfony/dotenv": "^7.4",
"symfony/error-handler": "^7.4",
"symfony/filesystem": "^7.4",
Expand Down
125 changes: 124 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ provider:
apiGateway:
binaryMediaTypes:
- '*/*'
minimumCompressionSize: 1024 # GZIP responses larger than 1 KB
environment:
BREF_BINARY_RESPONSES: '1'

Expand Down
26 changes: 19 additions & 7 deletions src/Cache/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,31 @@ public function __construct(
) {
}

public function createResponse(string $path, bool $supportWebp, Request $request): Response
{
public function createResponse(
string $path,
bool $supportAvif,
bool $supportWebp,
Request $request,
bool $varyAccept = true,
): Response {
$lastModified = (new \DateTimeImmutable())->setTimestamp($this->storage->lastModified($path));

$contentType = $this->storage->mimeType($path);
if (true === $supportAvif) {
$contentType = 'image/avif';
} elseif (true === $supportWebp) {
$contentType = 'image/webp';
}

$response = new StreamedResponse();
$response->headers->set(
'Content-Type',
(true === $supportWebp) ? 'image/webp' : $this->storage->mimeType($path),
);
$response->headers->set('Content-Type', $contentType);
$response->headers->set('Content-Length', (string) $this->storage->fileSize($path));
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->setPublic();
$response->headers->set('Vary', 'Accept');

if (true === $varyAccept) {
$response->headers->set('Vary', 'Accept');
}
$response->setMaxAge($this->ttl);
$response->setExpires((new \DateTimeImmutable())->modify("+$this->ttl seconds"));
$response->setLastModified($lastModified);
Expand Down
Loading
Loading