diff --git a/CLAUDE.md b/CLAUDE.md index 5197e0c..fc3e9a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. @@ -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/`) diff --git a/README.md b/README.md index 0e19012..dede6f2 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. @@ -129,6 +133,8 @@ https://cdn-php.loc ## Usage +### Images + You can fetch an optimized image by calling: ``` @@ -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: @@ -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. diff --git a/composer.json b/composer.json index 33eb1a1..a948e48 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 8d165a9..156b6d4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "baa0f6117233c6f438f64cf16a289be1", + "content-hash": "1e09ced707d694cf6b718d5fbb5361bc", "packages": [ { "name": "aws/aws-crt-php", @@ -1221,6 +1221,129 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "matthiasmullie/minify", + "version": "1.3.75", + "source": { + "type": "git", + "url": "https://github.com/matthiasmullie/minify.git", + "reference": "76ba4a5f555fd7bf4aa408af608e991569076671" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/matthiasmullie/minify/zipball/76ba4a5f555fd7bf4aa408af608e991569076671", + "reference": "76ba4a5f555fd7bf4aa408af608e991569076671", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "matthiasmullie/path-converter": "~1.1", + "php": ">=5.3.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": ">=2.0", + "matthiasmullie/scrapbook": ">=1.3", + "phpunit/phpunit": ">=4.8" + }, + "suggest": { + "psr/cache-implementation": "Cache implementation to use with Minify::cache" + }, + "bin": [ + "bin/minifycss", + "bin/minifyjs" + ], + "type": "library", + "autoload": { + "psr-4": { + "MatthiasMullie\\Minify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matthias Mullie", + "email": "minify@mullie.eu", + "homepage": "https://www.mullie.eu", + "role": "Developer" + } + ], + "description": "CSS & JavaScript minifier, in PHP. Removes whitespace, strips comments, combines files (incl. @import statements and small assets in CSS files), and optimizes/shortens a few common programming patterns.", + "homepage": "https://github.com/matthiasmullie/minify", + "keywords": [ + "JS", + "css", + "javascript", + "minifier", + "minify" + ], + "support": { + "issues": "https://github.com/matthiasmullie/minify/issues", + "source": "https://github.com/matthiasmullie/minify/tree/1.3.75" + }, + "funding": [ + { + "url": "https://github.com/matthiasmullie", + "type": "github" + } + ], + "time": "2025-06-25T09:56:19+00:00" + }, + { + "name": "matthiasmullie/path-converter", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/matthiasmullie/path-converter.git", + "reference": "e7d13b2c7e2f2268e1424aaed02085518afa02d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/matthiasmullie/path-converter/zipball/e7d13b2c7e2f2268e1424aaed02085518afa02d9", + "reference": "e7d13b2c7e2f2268e1424aaed02085518afa02d9", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "MatthiasMullie\\PathConverter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matthias Mullie", + "email": "pathconverter@mullie.eu", + "homepage": "http://www.mullie.eu", + "role": "Developer" + } + ], + "description": "Relative path converter", + "homepage": "http://github.com/matthiasmullie/path-converter", + "keywords": [ + "converter", + "path", + "paths", + "relative" + ], + "support": { + "issues": "https://github.com/matthiasmullie/path-converter/issues", + "source": "https://github.com/matthiasmullie/path-converter/tree/1.1.3" + }, + "time": "2019-02-05T23:41:09+00:00" + }, { "name": "mtdowling/jmespath.php", "version": "2.8.0", diff --git a/serverless.yml b/serverless.yml index aa9fce8..210963f 100644 --- a/serverless.yml +++ b/serverless.yml @@ -37,6 +37,7 @@ provider: apiGateway: binaryMediaTypes: - '*/*' + minimumCompressionSize: 1024 # GZIP responses larger than 1 KB environment: BREF_BINARY_RESPONSES: '1' diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index fe2ddd8..47e1683 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -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); diff --git a/src/Cdn.php b/src/Cdn.php index 3e67978..0a604e9 100644 --- a/src/Cdn.php +++ b/src/Cdn.php @@ -23,6 +23,7 @@ use BaBeuloula\CdnPhp\Exception\NotSupportedExtensionException; use BaBeuloula\CdnPhp\Processor\ImageProcessor; use BaBeuloula\CdnPhp\Processor\PathProcessor; +use BaBeuloula\CdnPhp\Processor\StaticAssetProcessor; use BaBeuloula\CdnPhp\Storage\Storage; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; @@ -30,6 +31,15 @@ final class Cdn { + /** @var string[] */ + private const array IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'heic']; + + /** @var string[] */ + private const array STATIC_EXTENSIONS = [ + 'css', 'js', 'woff', 'woff2', 'ttf', 'eot', 'otf', 'svg', 'ico', + 'xml', 'json', 'webmanifest', 'txt', 'map', + ]; + /** * @param string[] $allowedDomains * @param string[] $domainsAliases @@ -39,6 +49,7 @@ public function __construct( private readonly array $domainsAliases, private readonly Storage $storage, private readonly ImageProcessor $imageProcessor, + private readonly StaticAssetProcessor $staticAssetProcessor, private readonly Cache $cache, private readonly LoggerInterface $logger, private readonly string $forceToken = '', @@ -61,39 +72,127 @@ public function handleRequest(Request $request): Response return new Response($e->getMessage(), $e->getCode()); } + $extension = mb_strtolower(pathinfo($decoder->getImageUrl(), PATHINFO_EXTENSION)); + $isImage = \in_array($extension, self::IMAGE_EXTENSIONS, true); + $pathProcessor = new PathProcessor($decoder); - $supportWebp = $this->supportsWebp($request); + [$supportAvif, $supportWebp] = $this->detectOutputFormat($request, $isImage); - $cachedPath = $pathProcessor->getPath($supportWebp); + $cachedPath = $pathProcessor->getPath($supportAvif, $supportWebp); $force = $this->resolveForce($request); if (false === $this->storage->exists($cachedPath) || true === $force) { if (true === $force) { - $this->logger->info('Force re-fetch image: {image}', ['image' => $decoder->getImageUrl()]); + $this->logger->info('Force re-fetch: {url}', ['url' => $decoder->getImageUrl()]); } - try { - $originalPath = $this->storage->fetchImage($decoder->getImageUrl(), $decoder->getDomain(), $force); - } catch (FileTooLargeException | FileNotFoundException $e) { - return new Response($e->getMessage(), $e->getCode()); + $errorResponse = $this->fetchAndCache( + $decoder, + $extension, + $isImage, + $supportAvif, + $supportWebp, + $cachedPath, + $force, + ); + if (null !== $errorResponse) { + return $errorResponse; } + } - try { - $processedImage = $this->imageProcessor->process($originalPath, $decoder->getParams(), $supportWebp); - $this->storage->save($cachedPath, $this->storage->read($processedImage)); - } catch (\Throwable $e) { - $this->logger->error( - 'Image processing failed: {message}', - ['message' => $e->getMessage(), 'exception' => $e], - ); - return new Response('Image processing failed.', Response::HTTP_INTERNAL_SERVER_ERROR); - } + $this->logger->debug('Serve: {cachedPath}', ['cachedPath' => $cachedPath]); + + return $this->cache->createResponse($cachedPath, $supportAvif, $supportWebp, $request, varyAccept: $isImage); + } + + private function fetchAndCache( + UriDecoder $decoder, + string $extension, + bool $isImage, + bool $supportAvif, + bool $supportWebp, + string $cachedPath, + bool $force, + ): ?Response { + try { + $originalPath = $this->storage->fetchFile($decoder->getImageUrl(), $decoder->getDomain(), $force); + } catch (FileTooLargeException | FileNotFoundException $e) { + return new Response($e->getMessage(), $e->getCode()); + } + + if (true === $isImage) { + return $this->cacheImage($originalPath, $decoder, $supportAvif, $supportWebp, $cachedPath); + } + + return $this->cacheStaticAsset($originalPath, $extension, $cachedPath); + } + + private function cacheImage( + string $originalPath, + UriDecoder $decoder, + bool $supportAvif, + bool $supportWebp, + string $cachedPath, + ): ?Response { + try { + $processedImage = $this->imageProcessor->process( + $originalPath, + $decoder->getParams(), + $supportAvif, + $supportWebp, + ); + $this->storage->save($cachedPath, $this->storage->read($processedImage)); + + return null; + } catch (\Throwable $e) { + $this->logger->error( + 'Image processing failed: {message}', + ['message' => $e->getMessage(), 'exception' => $e], + ); + + return new Response('Image processing failed.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + private function cacheStaticAsset(string $originalPath, string $extension, string $cachedPath): ?Response + { + try { + $processedContent = $this->staticAssetProcessor->process($originalPath, $extension); + $this->storage->save($cachedPath, $processedContent); + + return null; + } catch (\Throwable $e) { + $this->logger->error( + 'Static asset processing failed: {message}', + ['message' => $e->getMessage(), 'exception' => $e], + ); + + return new Response('Static asset processing failed.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * @return array{bool, bool} + */ + private function detectOutputFormat(Request $request, bool $isImage): array + { + if (false === $isImage) { + return [false, false]; } - $this->logger->debug('Serve the image: {cachedPath}', ['cachedPath' => $cachedPath]); + $supportAvif = $this->supportsAvif($request); - return $this->cache->createResponse($cachedPath, $supportWebp, $request); + if (true === $supportAvif) { + return [true, false]; + } + + return [false, $this->supportsWebp($request)]; + } + + private function supportsAvif(Request $request): bool + { + return true === str_contains((string) $request->headers->get('Accept'), 'image/avif'); } private function supportsWebp(Request $request): bool @@ -133,11 +232,14 @@ private function validate(UriDecoder $decoder): void } $extension = mb_strtolower(pathinfo($decoder->getImageUrl(), PATHINFO_EXTENSION)); - if (false === \in_array($extension, ['png', 'jpg', 'jpeg', 'gif', 'webp'], true)) { + $allExtensions = [...self::IMAGE_EXTENSIONS, ...self::STATIC_EXTENSIONS]; + if (false === \in_array($extension, $allExtensions, true)) { throw new NotSupportedExtensionException($extension); } - if (null !== $decoder->getParams()->watermarkUrl) { + if (true === \in_array($extension, self::IMAGE_EXTENSIONS, true) + && null !== $decoder->getParams()->watermarkUrl + ) { $watermarkDomain = parse_url('https://' . $decoder->getParams()->watermarkUrl, PHP_URL_HOST); if (false === \is_string($watermarkDomain) || false === \in_array($watermarkDomain, $this->allowedDomains, true) diff --git a/src/Container.php b/src/Container.php index a631277..344bc96 100644 --- a/src/Container.php +++ b/src/Container.php @@ -18,6 +18,7 @@ use BaBeuloula\CdnPhp\Flysystem\Adapter\UrlFilesystemAdapter; use BaBeuloula\CdnPhp\Http\HttpFetcher; use BaBeuloula\CdnPhp\Processor\ImageProcessor; +use BaBeuloula\CdnPhp\Processor\StaticAssetProcessor; use BaBeuloula\CdnPhp\Storage\Storage; use Bref\Logger\StderrLogger as BrefLogger; use League\Flysystem\AwsS3V3\AwsS3V3Adapter; @@ -51,6 +52,7 @@ public function boot(): void $this->bootHttpFetcher(); $this->bootStorage(); $this->bootImageProcessor(); + $this->bootStaticAssetProcessor(); $this->bootCache(); $this->add(self::KEY_FORCE_TOKEN, $this->getEnv('FORCE_TOKEN') ?? ''); @@ -62,6 +64,7 @@ public function boot(): void $this->get(self::KEY_DOMAINS_ALIASES), $this->get(Storage::class), $this->get(ImageProcessor::class), + $this->get(StaticAssetProcessor::class), $this->get(Cache::class), $this->get(LoggerInterface::class), $this->get(self::KEY_FORCE_TOKEN), @@ -233,4 +236,15 @@ private function bootImageProcessor(): void ), ); } + + private function bootStaticAssetProcessor(): void + { + $this->add( + StaticAssetProcessor::class, + new StaticAssetProcessor( + $this->get(FilesystemAdapter::class), + $this->get(LoggerInterface::class), + ), + ); + } } diff --git a/src/Processor/ImageProcessor.php b/src/Processor/ImageProcessor.php index 773b43e..a15d1aa 100644 --- a/src/Processor/ImageProcessor.php +++ b/src/Processor/ImageProcessor.php @@ -31,8 +31,12 @@ public function __construct( ) { } - public function process(string $path, QueryParams $params, bool $outputWebp = false): string - { + public function process( + string $path, + QueryParams $params, + bool $outputAvif = false, + bool $outputWebp = false, + ): string { $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); if ('gif' === $extension) { @@ -55,15 +59,26 @@ public function process(string $path, QueryParams $params, bool $outputWebp = fa ], ); + $glideParams = [...$params->toArray(), 'q' => $this->imageCompression]; + + if (true === $outputAvif) { + $glideParams['fm'] = 'avif'; + } elseif (true === $outputWebp) { + $glideParams['fm'] = 'webp'; + } elseif (true === \in_array($extension, ['avif', 'heic'], true)) { + // Non-web-safe formats: always transcode to JPEG as browser-safe fallback + $glideParams['fm'] = 'jpg'; + } + $this->logger->info( 'Process image: {path} with params {params}', [ 'path' => $path, - 'params' => json_encode($params->toArray(), flags: JSON_THROW_ON_ERROR), + 'params' => json_encode($glideParams, flags: JSON_THROW_ON_ERROR), ] ); - return $server->makeImage(basename($path), [...$params->toArray(), 'q' => $this->imageCompression]); + return $server->makeImage(basename($path), $glideParams); } private function processAnimated(string $path, QueryParams $params, bool $outputWebp = false): string diff --git a/src/Processor/PathProcessor.php b/src/Processor/PathProcessor.php index b9e17dc..511e08e 100644 --- a/src/Processor/PathProcessor.php +++ b/src/Processor/PathProcessor.php @@ -18,6 +18,17 @@ final class PathProcessor { + /** + * Formats that cannot be served directly by browsers and must be transcoded. + * The value is the web-safe fallback extension used as the base cache path. + * + * @var array + */ + private const array TRANSCODED_EXTENSIONS = [ + 'avif' => 'jpg', + 'heic' => 'jpg', + ]; + private string $path; public function __construct(private readonly UriDecoder $decoder) @@ -25,9 +36,17 @@ public function __construct(private readonly UriDecoder $decoder) $this->generatePath(); } - public function getPath(bool $supportWebp = false): string + public function getPath(bool $supportAvif = false, bool $supportWebp = false): string { - return $this->path . ((true === $supportWebp) ? '.webp' : ''); + if (true === $supportAvif) { + return $this->path . '.avif'; + } + + if (true === $supportWebp) { + return $this->path . '.webp'; + } + + return $this->path; } private function generatePath(): void @@ -45,7 +64,8 @@ private function generatePath(): void } $path = implode('/', $parts); - $extension = pathinfo($this->decoder->getImageUrl(), PATHINFO_EXTENSION); + $sourceExtension = strtolower(pathinfo($this->decoder->getImageUrl(), PATHINFO_EXTENSION)); + $extension = self::TRANSCODED_EXTENSIONS[$sourceExtension] ?? $sourceExtension; $filename = md5($this->decoder->getImageUrl()) . '.' . $extension; $this->path = sprintf( diff --git a/src/Processor/StaticAssetProcessor.php b/src/Processor/StaticAssetProcessor.php new file mode 100644 index 0000000..26c38a3 --- /dev/null +++ b/src/Processor/StaticAssetProcessor.php @@ -0,0 +1,48 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhp\Processor; + +use League\Flysystem\FilesystemAdapter; +use MatthiasMullie\Minify; +use Psr\Log\LoggerInterface; + +final class StaticAssetProcessor +{ + private const array MINIFIABLE_CSS = ['css']; + private const array MINIFIABLE_JS = ['js']; + + public function __construct( + private readonly FilesystemAdapter $adapter, + private readonly LoggerInterface $logger, + ) { + } + + public function process(string $path, string $extension): string + { + $content = $this->adapter->read($path); + $extension = strtolower($extension); + + if (true === \in_array($extension, self::MINIFIABLE_CSS, true)) { + $minifier = new Minify\CSS($content); + $content = $minifier->minify(); + $this->logger->info('Minified CSS: {path}', ['path' => $path]); + } elseif (true === \in_array($extension, self::MINIFIABLE_JS, true)) { + $minifier = new Minify\JS($content); + $content = $minifier->minify(); + $this->logger->info('Minified JS: {path}', ['path' => $path]); + } + + return $content; + } +} diff --git a/src/Storage/Storage.php b/src/Storage/Storage.php index 2f7644c..0a3e44e 100644 --- a/src/Storage/Storage.php +++ b/src/Storage/Storage.php @@ -28,12 +28,12 @@ public function __construct( ) { } - public function fetchImage(string $imageUrl, string $domain, bool $force = false): string + public function fetchFile(string $url, string $domain, bool $force = false): string { - $this->logger->debug('Fetching image: {imageUrl}', ['imageUrl' => $imageUrl]); + $this->logger->debug('Fetching file: {url}', ['url' => $url]); - $extension = pathinfo($imageUrl, PATHINFO_EXTENSION); - $filename = md5($imageUrl) . '.' . $extension; + $extension = pathinfo($url, PATHINFO_EXTENSION); + $filename = md5($url) . '.' . $extension; $path = sprintf( '%s/original/%s', $domain, @@ -41,17 +41,17 @@ public function fetchImage(string $imageUrl, string $domain, bool $force = false ); if (true === $this->exists($path) && false === $force) { - $this->logger->debug('Original image already saved: {path}', ['path' => $path]); + $this->logger->debug('Original file already saved: {path}', ['path' => $path]); return $path; } try { - $content = $this->httpFetcher->fetch($imageUrl); + $content = $this->httpFetcher->fetch($url); } catch (FileTooLargeException $e) { throw $e; } catch (\RuntimeException $e) { - throw new FileNotFoundException($imageUrl, $e); + throw new FileNotFoundException($url, $e); } $this->save($path, $content); @@ -87,7 +87,7 @@ public function lastModified(string $path): int public function save(string $path, string $content): void { - $this->logger->debug('Save image on storage: {path}', ['path' => $path]); + $this->logger->debug('Save file on storage: {path}', ['path' => $path]); $this->filesystem->write($path, $content); } diff --git a/tests/Cache/CacheTest.php b/tests/Cache/CacheTest.php index 610348d..124bd23 100644 --- a/tests/Cache/CacheTest.php +++ b/tests/Cache/CacheTest.php @@ -45,7 +45,7 @@ public function canCreateAResponseWithoutWebpSupport(): void /** @var Cache $cache */ $cache = $this->getContainer(Cache::class); - $response = $cache->createResponse(static::TEST_FILENAME, false, new Request()); + $response = $cache->createResponse(static::TEST_FILENAME, false, false, new Request()); static::assertInstanceOf(StreamedResponse::class, $response); static::assertSame(Response::HTTP_OK, $response->getStatusCode()); @@ -63,24 +63,46 @@ public function canCreateAResponseWithWebpSupport(): void /** @var Cache $cache */ $cache = $this->getContainer(Cache::class); - $response = $cache->createResponse(static::TEST_FILENAME, true, new Request()); + $response = $cache->createResponse(static::TEST_FILENAME, false, true, new Request()); static::assertSame('image/webp', $response->headers->get('Content-Type')); } + #[Test] + public function canCreateAResponseWithAvifSupport(): void + { + /** @var Cache $cache */ + $cache = $this->getContainer(Cache::class); + + $response = $cache->createResponse(static::TEST_FILENAME, true, false, new Request()); + + static::assertSame('image/avif', $response->headers->get('Content-Type')); + } + + #[Test] + public function canCreateAResponseWithoutVaryAccept(): void + { + /** @var Cache $cache */ + $cache = $this->getContainer(Cache::class); + + $response = $cache->createResponse(static::TEST_FILENAME, false, false, new Request(), varyAccept: false); + + static::assertNull($response->headers->get('Vary')); + } + #[Test] public function canCreateANotModifiedResponse(): void { /** @var Cache $cache */ $cache = $this->getContainer(Cache::class); - $firstResponse = $cache->createResponse(static::TEST_FILENAME, false, new Request()); + $firstResponse = $cache->createResponse(static::TEST_FILENAME, false, false, new Request()); $etag = $firstResponse->headers->get('ETag'); $request = new Request(); $request->headers->set('If-None-Match', $etag); - $response = $cache->createResponse(static::TEST_FILENAME, false, $request); + $response = $cache->createResponse(static::TEST_FILENAME, false, false, $request); static::assertSame(Response::HTTP_NOT_MODIFIED, $response->getStatusCode()); } diff --git a/tests/Cdn/CdnTest.php b/tests/Cdn/CdnTest.php index 48c5b19..8b912b8 100644 --- a/tests/Cdn/CdnTest.php +++ b/tests/Cdn/CdnTest.php @@ -78,7 +78,7 @@ public function cantHandleRequestInvalidUri(): void #[Test] public function cantHandleRequestNotSupportedExtension(): void { - $request = Request::create('http://mycdn.com/http://example.com/style.css'); + $request = Request::create('http://mycdn.com/http://example.com/page.php'); $response = $this->cdn->handleRequest($request); @@ -244,4 +244,142 @@ public function canHandleAnimatedWebpRequest(): void static::assertSame(Response::HTTP_OK, $response->getStatusCode()); static::assertSame('image/webp', $response->headers->get('Content-Type')); } + + #[Test] + public function canHandleRequestWithCssAsset(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_CSS_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertSame('text/css', $response->headers->get('Content-Type')); + static::assertNull($response->headers->get('Vary')); + } + + #[Test] + public function canHandleRequestWithJsAsset(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_JS_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertStringContainsString('javascript', (string) $response->headers->get('Content-Type')); + static::assertNull($response->headers->get('Vary')); + } + + #[Test] + public function canHandleRequestWithFontAsset(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_WOFF2_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertNull($response->headers->get('Vary')); + } + + #[Test] + public function canHandleRequestWithXmlAsset(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_XML_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertNull($response->headers->get('Vary')); + } + + #[Test] + public function staticAssetsIgnoreWebpAcceptHeader(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_CSS_URI); + $request->headers->set('Accept', 'image/webp,*/*'); + + $response = $this->cdn->handleRequest($request); + + // CSS assets must never be served as WebP regardless of Accept header + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertSame('text/css', $response->headers->get('Content-Type')); + } + + #[Test] + public function canHandleRequestWithAvifOutput(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_BASE_URI); + $request->headers->set('Accept', 'image/avif,*/*'); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertSame('image/avif', $response->headers->get('Content-Type')); + } + + #[Test] + public function avifOutputTakesPriorityOverWebp(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_BASE_URI); + $request->headers->set('Accept', 'image/avif,image/webp,*/*'); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertSame('image/avif', $response->headers->get('Content-Type')); + } + + #[Test] + public function canRecognizeHeicExtensionAsValidImageFormat(): void + { + // HEIC is a valid image extension — must not be rejected with 400 (unsupported extension) + $request = Request::create('http://mycdn.com/' . static::TEST_HEIC_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + } + + #[Test] + public function canHandleRequestWithJsonAsset(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_JSON_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertNull($response->headers->get('Vary')); + } + + #[Test] + public function canHandleRequestWithWebmanifestAsset(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_WEBMANIFEST_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertNull($response->headers->get('Vary')); + } + + #[Test] + public function canHandleRequestWithTxtAsset(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_TXT_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertNull($response->headers->get('Vary')); + } + + #[Test] + public function canHandleRequestWithMapAsset(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_MAP_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertNull($response->headers->get('Vary')); + } } diff --git a/tests/Processor/ImageProcessorTest.php b/tests/Processor/ImageProcessorTest.php index d4586b6..2c6a191 100644 --- a/tests/Processor/ImageProcessorTest.php +++ b/tests/Processor/ImageProcessorTest.php @@ -114,7 +114,7 @@ public function canProcessAnimatedGifAsWebp(): void /** @var ImageProcessor $imageProcessor */ $imageProcessor = $this->getContainer(ImageProcessor::class); - $resultPath = $imageProcessor->process(static::TEST_GIF_FILENAME, QueryParams::fromArray([]), true); + $resultPath = $imageProcessor->process(static::TEST_GIF_FILENAME, QueryParams::fromArray([]), false, true); static::assertSame(static::TEST_GIF_WEBP_CACHE_PATH, $resultPath); diff --git a/tests/Processor/PathProcessorTest.php b/tests/Processor/PathProcessorTest.php index 1312930..987c72c 100644 --- a/tests/Processor/PathProcessorTest.php +++ b/tests/Processor/PathProcessorTest.php @@ -47,7 +47,7 @@ public function canGetPathWithWebp(string $path, array $queryParams): void static::assertSame( static::TEST_DOMAIN . $path . '/' . static::TEST_FILENAME_MD5 . '.webp', - $pathProcessor->getPath(true) + $pathProcessor->getPath(false, true) ); } @@ -66,6 +66,51 @@ public static function queryParametersProvider(): \Generator yield ['w100/h100', ['w' => 100, 'h' => 100]]; } + #[DataProvider('queryParametersProvider')] + #[Test] + public function canGetPathWithAvif(string $path, array $queryParams): void + { + $decoder = new UriDecoder(static::TEST_BASE_URI . '?' . http_build_query($queryParams)); + $pathProcessor = new PathProcessor($decoder); + + $path = ('' === $path) ? '' : "/$path"; + + static::assertSame( + static::TEST_DOMAIN . $path . '/' . static::TEST_FILENAME_MD5 . '.avif', + $pathProcessor->getPath(true, false) + ); + } + + #[Test] + public function avifTakesPriorityOverWebpInPath(): void + { + $decoder = new UriDecoder(static::TEST_BASE_URI); + $pathProcessor = new PathProcessor($decoder); + + static::assertSame( + static::TEST_DOMAIN . '/w0/' . static::TEST_FILENAME_MD5 . '.avif', + $pathProcessor->getPath(true, true) + ); + } + + #[Test] + public function canGetPathForTranscodedExtensions(): void + { + foreach (['avif', 'heic'] as $ext) { + $imageUri = "https://example.com/image.{$ext}"; + $decoder = new UriDecoder($imageUri); + $pathProcessor = new PathProcessor($decoder); + + // AVIF and HEIC use .jpg as base extension for browser-safe fallback + $expectedFilename = md5($imageUri) . '.jpg'; + static::assertSame( + static::TEST_DOMAIN . '/w0/' . $expectedFilename, + $pathProcessor->getPath(), + "Failed for extension: {$ext}", + ); + } + } + #[Test] public function canGetPathWithDifferentExtensions(): void { diff --git a/tests/Processor/StaticAssetProcessorTest.php b/tests/Processor/StaticAssetProcessorTest.php new file mode 100644 index 0000000..4151cb0 --- /dev/null +++ b/tests/Processor/StaticAssetProcessorTest.php @@ -0,0 +1,79 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhp\Tests\Processor; + +use BaBeuloula\CdnPhp\Processor\StaticAssetProcessor; +use BaBeuloula\CdnPhp\Tests\TestCase; +use League\Flysystem\Config; +use League\Flysystem\FilesystemAdapter; +use PHPUnit\Framework\Attributes\Test; + +class StaticAssetProcessorTest extends TestCase +{ + private FilesystemAdapter $adapter; + private StaticAssetProcessor $processor; + + protected function setUp(): void + { + parent::setUp(); + + /** @var FilesystemAdapter $adapter */ + $adapter = $this->getContainer(FilesystemAdapter::class); + $this->adapter = $adapter; + + $this->adapter->write(static::TEST_CSS_FILENAME, static::getTestCssContent(), new Config()); + $this->adapter->write(static::TEST_JS_FILENAME, static::getTestJsContent(), new Config()); + $this->adapter->write(static::TEST_WOFF2_FILENAME, static::getTestFontContent(), new Config()); + + /** @var StaticAssetProcessor $processor */ + $processor = $this->getContainer(StaticAssetProcessor::class); + $this->processor = $processor; + } + + #[Test] + public function canMinifyCss(): void + { + $result = $this->processor->process(static::TEST_CSS_FILENAME, 'css'); + + static::assertStringNotContainsString('/* comment */', $result); + static::assertStringContainsString('color:red', $result); + static::assertStringContainsString('margin:0', $result); + } + + #[Test] + public function canMinifyJs(): void + { + $result = $this->processor->process(static::TEST_JS_FILENAME, 'js'); + + static::assertStringNotContainsString('// comment', $result); + static::assertStringContainsString('function hello()', $result); + } + + #[Test] + public function canMinifyCssWithUppercaseExtension(): void + { + $result = $this->processor->process(static::TEST_CSS_FILENAME, 'CSS'); + + static::assertStringNotContainsString('/* comment */', $result); + static::assertStringContainsString('color:red', $result); + } + + #[Test] + public function canPassthroughFontAsset(): void + { + $result = $this->processor->process(static::TEST_WOFF2_FILENAME, 'woff2'); + + static::assertSame(static::getTestFontContent(), $result); + } +} diff --git a/tests/Storage/StorageTest.php b/tests/Storage/StorageTest.php index d2c10c4..96f4d30 100644 --- a/tests/Storage/StorageTest.php +++ b/tests/Storage/StorageTest.php @@ -42,7 +42,7 @@ public function canFetchAnImage(): void { static::assertSame( static::TEST_ORIGINAL_PATH, - $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain()), + $this->storage->fetchFile($this->decoder->getImageUrl(), $this->decoder->getDomain()), ); } @@ -53,7 +53,7 @@ public function canFetchAnExistingImage(): void static::assertSame( static::TEST_ORIGINAL_PATH, - $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain()), + $this->storage->fetchFile($this->decoder->getImageUrl(), $this->decoder->getDomain()), ); } @@ -62,7 +62,7 @@ public function canForceFetchAnAlreadyCachedImage(): void { $this->storage->save(static::TEST_ORIGINAL_PATH, 'stale_content'); - $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain(), true); + $this->storage->fetchFile($this->decoder->getImageUrl(), $this->decoder->getDomain(), true); static::assertNotSame('stale_content', $this->storage->read(static::TEST_ORIGINAL_PATH)); } @@ -76,13 +76,13 @@ public function cantFetchANotfoundImage(): void $storage = $this->getContainer(Storage::class); static::expectException(FileNotFoundException::class); - $storage->fetchImage($decoder->getImageUrl(), $decoder->getDomain()); + $storage->fetchFile($decoder->getImageUrl(), $decoder->getDomain()); } #[Test] public function canReadAnImage(): void { - $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain()); + $this->storage->fetchFile($this->decoder->getImageUrl(), $this->decoder->getDomain()); static::assertIsString($this->storage->read(static::TEST_ORIGINAL_PATH)); } @@ -90,7 +90,7 @@ public function canReadAnImage(): void #[Test] public function canReadAsStreamAnImage(): void { - $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain()); + $this->storage->fetchFile($this->decoder->getImageUrl(), $this->decoder->getDomain()); static::assertIsResource($this->storage->readStream(static::TEST_ORIGINAL_PATH)); } @@ -98,7 +98,7 @@ public function canReadAsStreamAnImage(): void #[Test] public function canGetMimetypeOfAnImage(): void { - $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain()); + $this->storage->fetchFile($this->decoder->getImageUrl(), $this->decoder->getDomain()); static::assertSame( 'image/jpeg', @@ -109,7 +109,7 @@ public function canGetMimetypeOfAnImage(): void #[Test] public function canGetFilesizeOfAnImage(): void { - $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain()); + $this->storage->fetchFile($this->decoder->getImageUrl(), $this->decoder->getDomain()); static::assertGreaterThan(0, $this->storage->fileSize(static::TEST_ORIGINAL_PATH)); } @@ -117,7 +117,7 @@ public function canGetFilesizeOfAnImage(): void #[Test] public function canGetLastModifiedOfAnImage(): void { - $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain()); + $this->storage->fetchFile($this->decoder->getImageUrl(), $this->decoder->getDomain()); static::assertGreaterThan(0, $this->storage->lastModified(static::TEST_ORIGINAL_PATH)); } diff --git a/tests/TestCase.php b/tests/TestCase.php index 2fad008..c383b96 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -42,6 +42,18 @@ class TestCase extends BaseTestCase protected const string TEST_GIF_FILENAME = 'image.gif'; protected const string TEST_GIF_CACHE_PATH = './cache/image.gif'; protected const string TEST_GIF_WEBP_CACHE_PATH = './cache/image.webp'; + protected const string TEST_CSS_URI = 'https://example.com/style.css'; + protected const string TEST_CSS_FILENAME = 'style.css'; + protected const string TEST_JS_URI = 'https://example.com/app.js'; + protected const string TEST_JS_FILENAME = 'app.js'; + protected const string TEST_WOFF2_URI = 'https://example.com/font.woff2'; + protected const string TEST_WOFF2_FILENAME = 'font.woff2'; + protected const string TEST_XML_URI = 'https://example.com/sitemap.xml'; + protected const string TEST_JSON_URI = 'https://example.com/manifest.json'; + protected const string TEST_WEBMANIFEST_URI = 'https://example.com/app.webmanifest'; + protected const string TEST_TXT_URI = 'https://example.com/robots.txt'; + protected const string TEST_MAP_URI = 'https://example.com/app.js.map'; + protected const string TEST_HEIC_URI = 'https://example.com/photo.heic'; private Container $container; @@ -75,6 +87,38 @@ static function (string $url) { throw new FileTooLargeException($url, 1); } + if (static::TEST_CSS_URI === $url) { + return static::getTestCssContent(); + } + + if (static::TEST_JS_URI === $url) { + return static::getTestJsContent(); + } + + if (static::TEST_WOFF2_URI === $url) { + return static::getTestFontContent(); + } + + if (static::TEST_XML_URI === $url) { + return static::getTestXmlContent(); + } + + if (static::TEST_JSON_URI === $url) { + return static::getTestJsonContent(); + } + + if (static::TEST_WEBMANIFEST_URI === $url) { + return static::getTestWebmanifestContent(); + } + + if (static::TEST_TXT_URI === $url) { + return static::getTestTxtContent(); + } + + if (static::TEST_MAP_URI === $url) { + return static::getTestMapContent(); + } + throw new \RuntimeException("URL not mocked: {$url}"); }, ) @@ -109,6 +153,46 @@ protected static function getTestImageContent(): string return (string) file_get_contents(__DIR__ . '/fixtures/image.jpg'); } + protected static function getTestCssContent(): string + { + return "/* comment */ body { color: red; }\n\np { margin: 0; }"; + } + + protected static function getTestJsContent(): string + { + return "// comment\nfunction hello() { return 'world'; }"; + } + + protected static function getTestFontContent(): string + { + return 'woff2-binary-font-content'; + } + + protected static function getTestXmlContent(): string + { + return 'https://example.com/'; + } + + protected static function getTestJsonContent(): string + { + return '{"name":"My App","version":"1.0"}'; + } + + protected static function getTestWebmanifestContent(): string + { + return '{"name":"My App","icons":[]}'; + } + + protected static function getTestTxtContent(): string + { + return "User-agent: *\nDisallow:"; + } + + protected static function getTestMapContent(): string + { + return '{"version":3,"sources":["app.js"],"mappings":""}'; + } + protected static function getTestAnimatedGifContent(): string { $animation = new \Imagick();