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
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ FETCH_ALLOW_REDIRECTS=0

# Force re-fetch token (empty = no protection)
FORCE_TOKEN=

# Signed URL secret (empty = URL signing disabled)
SIGNATURE_SECRET=
118 changes: 102 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,26 @@
## Overview

This project is a lightweight CDN built with PHP.
It supports fetching, optimizing, caching and serving images dynamically while ensuring high flexibility and efficiency.
It supports fetching, optimizing, caching, and serving images and static assets dynamically while ensuring high flexibility and efficiency.

## Features

- **Domain Restriction:** Allows defining authorized domains via an environment variable (_ALLOWED_DOMAINS_).
- **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.
- **Domain Restriction:** Allows defining authorized domains via an environment variable (`ALLOWED_DOMAINS`).
- **On-the-Fly Image Processing:** Fetches images from a URL, compresses them, and caches them.
- **WebP & AVIF Support:** Converts images to WebP or AVIF format based on the client's `Accept` header (AVIF takes priority).
- **Animated GIF Support:** Preserves all frames of animated GIFs through resize operations; converts to animated WebP on demand.
- **EXIF Stripping:** Automatically removes EXIF metadata (GPS, device model…) from processed images to protect user privacy.
- **Dominant Color Header:** Returns an `X-Dominant-Color: #rrggbb` header on image responses for use as a placeholder while the image loads.
- **Static Asset Support:** Fetches, optimizes, and serves static files with proper cache headers:
- **CSS & JS minification:** Automatically minifies `.css` and `.js` files.
- **JSON minification:** Minifies `.json` and `.webmanifest` files.
- **Font passthrough:** Serves `.woff`, `.woff2`, `.ttf`, `.eot`, `.otf` with long-term caching.
- **SVG & ICO passthrough:** Serves `.svg` and `.ico` with long-term caching.
- **Other passthroughs:** Serves `.xml`, `.txt`, `.map`, `.wasm` as-is with long-term caching.
- **Signed URLs:** Optional HMAC-SHA256 URL signing with expiration (`SIGNATURE_SECRET`). When enabled, requests must carry `?expires=<timestamp>&sig=<hmac>`.
- **SSRF Protection:** Blocks requests targeting private/reserved IP ranges (loopback, link-local, RFC 1918, etc.) in addition to domain allowlisting.
- **Fetch Hardening:** Configurable timeout, maximum file size, and redirect policy to prevent slow-loris, image-bomb, and SSRF-via-redirect attacks.
- **Force Re-fetch Protection:** Optional secret token required to bypass the cache (`FORCE_TOKEN`).
- **Configurable Storage:** Supports both local filesystem and S3-compatible storage.
- **Dynamic Image Resizing:** Resize images via query parameters:
- `w` (width)
Expand All @@ -23,11 +31,8 @@ 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:** Assets are stored based on query parameters.
- **Smart Storage Structure:** Assets are stored based on query parameters for deterministic cache keys.
- **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.
- **Force Re-fetch Protection:** Optional secret token required to bypass the cache (`FORCE_TOKEN`).

## Serverless

Expand Down Expand Up @@ -110,11 +115,14 @@ IMAGE_COMPRESSION=75
# HTTP fetch (timeout in seconds, max size in bytes)
FETCH_TIMEOUT=10
FETCH_MAX_SIZE=52428800
# Set to 1 only if your image origins serve via redirects (SSRF risk see security notes)
# Set to 1 only if your image origins serve via redirects (SSRF risk - see security notes)
FETCH_ALLOW_REDIRECTS=0

# Force re-fetch token (empty = no protection, set to a secret to require ?token=<value>)
FORCE_TOKEN=

# URL signing secret (empty = disabled; when set, all requests must carry ?expires=<ts>&sig=<hmac>)
SIGNATURE_SECRET=
```

## Running with Docker
Expand Down Expand Up @@ -143,10 +151,12 @@ https://cdn-php.loc/https://www.mysite.com/image.png?w=200&h=200

The CDN will:
- Fetch the image from www.mysite.com
- Strip EXIF metadata
- Optimize and compress it
- Convert it to WebP if supported
- Convert it to AVIF or WebP if the client supports it
- Store it based on parameters
- Serve it with proper caching headers (`Cache-Control`, `ETag`, `Vary: Accept`)
- Add `X-Dominant-Color: #rrggbb` for use as a CSS placeholder

### Static Assets

Expand All @@ -159,20 +169,96 @@ https://cdn-php.loc/https://www.mysite.com/style.css
# JavaScript (automatically minified)
https://cdn-php.loc/https://www.mysite.com/app.js

# JSON / Web App Manifest (automatically minified)
https://cdn-php.loc/https://www.mysite.com/manifest.json
https://cdn-php.loc/https://www.mysite.com/app.webmanifest

# 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

# Other passthroughs
https://cdn-php.loc/https://www.mysite.com/robots.txt
https://cdn-php.loc/https://www.mysite.com/app.js.map
https://cdn-php.loc/https://www.mysite.com/module.wasm
```

Supported extensions: `css`, `js`, `woff`, `woff2`, `ttf`, `eot`, `otf`, `svg`, `ico`, `xml`
Supported extensions: `css`, `js`, `woff`, `woff2`, `ttf`, `eot`, `otf`, `svg`, `ico`, `xml`, `json`, `webmanifest`, `txt`, `map`, `wasm`

### Signed URLs

When `SIGNATURE_SECRET` is set, every CDN request must carry a valid HMAC-SHA256 signature. This prevents anyone from constructing arbitrary CDN URLs directly – only your backend can generate valid ones.

**How it works:**

1. Your backend generates a signed URL and injects it into the HTML.
2. The browser fetches the CDN URL (with the signature).
3. The CDN verifies the signature before serving the asset.

**What gets signed:**

The signature covers the **source URL** (the image/asset origin, without CDN params) and the expiry timestamp:

```
HMAC-SHA256( "<source_url>:<expires>", SIGNATURE_SECRET )
```

**PHP helper (in your application backend):**

```php
function cdnUrl(
string $cdnBase,
string $sourceUrl,
int $ttl = 3600,
array $params = [],
): string {
$expires = time() + $ttl;
$sig = hash_hmac('sha256', $sourceUrl . ':' . $expires, $_ENV['SIGNATURE_SECRET']);

return $cdnBase . '/' . $sourceUrl . '?' . http_build_query(
array_merge($params, ['expires' => $expires, 'sig' => $sig])
);
}
```

**Usage in a Twig template (for example):**

```php
// In your controller
$imageUrl = cdnUrl(
cdnBase: 'https://cdn.mysite.com',
sourceUrl: 'https://www.mysite.com/uploads/photo.jpg',
ttl: 3600, // link valid for 1 hour
params: ['w' => 800, 'h' => 600],
);
```

```html
<!-- In your template -->
<img src="{{ imageUrl }}" alt="Photo">
```

This produces a URL like:

```
https://cdn.mysite.com/https://www.mysite.com/uploads/photo.jpg
?w=800&h=600&expires=1714000000&sig=a3f2c1...
```

**Error responses:**

| Situation | HTTP status |
|------------------------------------|-----------------|
| `sig` missing or incorrect | `403 Forbidden` |
| `expires` timestamp is in the past | `410 Gone` |

CSS and JS files are automatically minified (comments and unnecessary whitespace removed) before being cached, reducing their size for faster delivery.
> **Important:** `SIGNATURE_SECRET` must be kept server-side only. Never expose it in front-end code or public repositories.

### 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.
**With the serverless setup (Bref / API Gateway):** API Gateway handles GZIP compression automatically 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:

Expand Down
2 changes: 1 addition & 1 deletion phpmd-ruleset.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
</rule>
<rule ref="rulesets/design.xml/CouplingBetweenObjects">
<properties>
<property name="maximum" value="20"/>
<property name="maximum" value="22"/>
</properties>
</rule>
<rule ref="rulesets/naming.xml">
Expand Down
5 changes: 5 additions & 0 deletions src/Cache/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ public function createResponse(

if (true === $varyAccept) {
$response->headers->set('Vary', 'Accept');
try {
$response->headers->set('X-Dominant-Color', $this->storage->read($path . '.color'));
} catch (\Throwable) {
// No color sidecar available - omit the header
}
}
$response->setMaxAge($this->ttl);
$response->setExpires((new \DateTimeImmutable())->modify("+$this->ttl seconds"));
Expand Down
46 changes: 37 additions & 9 deletions src/Cdn.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@

use BaBeuloula\CdnPhp\Cache\Cache;
use BaBeuloula\CdnPhp\Decoder\UriDecoder;
use BaBeuloula\CdnPhp\Exception\CdnException;
use BaBeuloula\CdnPhp\Exception\EmptyUriException;
use BaBeuloula\CdnPhp\Exception\FileNotFoundException;
use BaBeuloula\CdnPhp\Exception\FileTooLargeException;
use BaBeuloula\CdnPhp\Exception\InvalidUriException;
use BaBeuloula\CdnPhp\Exception\NotAllowedDomainException;
use BaBeuloula\CdnPhp\Exception\NotSupportedExtensionException;
use BaBeuloula\CdnPhp\Processor\ImageProcessor;
use BaBeuloula\CdnPhp\Processor\PathProcessor;
use BaBeuloula\CdnPhp\Processor\StaticAssetProcessor;
use BaBeuloula\CdnPhp\Security\UrlSigner;
use BaBeuloula\CdnPhp\Storage\Storage;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
Expand All @@ -37,7 +37,7 @@ final class Cdn
/** @var string[] */
private const array STATIC_EXTENSIONS = [
'css', 'js', 'woff', 'woff2', 'ttf', 'eot', 'otf', 'svg', 'ico',
'xml', 'json', 'webmanifest', 'txt', 'map',
'xml', 'json', 'webmanifest', 'txt', 'map', 'wasm',
];

/**
Expand All @@ -53,6 +53,7 @@ public function __construct(
private readonly Cache $cache,
private readonly LoggerInterface $logger,
private readonly string $forceToken = '',
private readonly ?UrlSigner $urlSigner = null,
) {
}

Expand All @@ -68,10 +69,15 @@ public function handleRequest(Request $request): Response
$this->validate($decoder);
} catch (EmptyUriException) {
return new Response('Welcome to your CDN PHP (https://github.com/babeuloula/cdn-php)', Response::HTTP_OK);
} catch (InvalidUriException | NotSupportedExtensionException | NotAllowedDomainException $e) {
} catch (CdnException $e) {
return new Response($e->getMessage(), $e->getCode());
}

$signatureError = $this->validateSignature($request, $decoder);
if (null !== $signatureError) {
return $signatureError;
}

$extension = mb_strtolower(pathinfo($decoder->getImageUrl(), PATHINFO_EXTENSION));
$isImage = \in_array($extension, self::IMAGE_EXTENSIONS, true);

Expand Down Expand Up @@ -106,6 +112,25 @@ public function handleRequest(Request $request): Response
return $this->cache->createResponse($cachedPath, $supportAvif, $supportWebp, $request, varyAccept: $isImage);
}

private function validateSignature(Request $request, UriDecoder $decoder): ?Response
{
if (null === $this->urlSigner) {
return null;
}

try {
$this->urlSigner->verify(
$decoder->getImageUrl(),
$request->query->getInt('expires'),
(string) $request->query->get('sig', ''),
);
} catch (CdnException $e) {
return new Response($e->getMessage(), $e->getCode());
}

return null;
}

private function fetchAndCache(
UriDecoder $decoder,
string $extension,
Expand All @@ -117,7 +142,7 @@ private function fetchAndCache(
): ?Response {
try {
$originalPath = $this->storage->fetchFile($decoder->getImageUrl(), $decoder->getDomain(), $force);
} catch (FileTooLargeException | FileNotFoundException $e) {
} catch (CdnException $e) {
return new Response($e->getMessage(), $e->getCode());
}

Expand All @@ -144,6 +169,11 @@ private function cacheImage(
);
$this->storage->save($cachedPath, $this->storage->read($processedImage));

$dominantColor = $this->imageProcessor->extractDominantColor($originalPath);
if (null !== $dominantColor) {
$this->storage->save($cachedPath . '.color', $dominantColor);
}

return null;
} catch (\Throwable $e) {
$this->logger->error(
Expand Down Expand Up @@ -213,9 +243,7 @@ private function resolveForce(Request $request): bool

/**
* @throws EmptyUriException
* @throws InvalidUriException
* @throws NotAllowedDomainException
* @throws NotSupportedExtensionException
* @throws CdnException
*/
private function validate(UriDecoder $decoder): void
{
Expand All @@ -228,7 +256,7 @@ private function validate(UriDecoder $decoder): void
}

if (false === \in_array($decoder->getDomain(), $this->allowedDomains, true)) {
throw new NotAllowedDomainException($decoder->getDomain());
throw new NotAllowedDomainException($decoder->getDomain());
}

$extension = mb_strtolower(pathinfo($decoder->getImageUrl(), PATHINFO_EXTENSION));
Expand Down
8 changes: 8 additions & 0 deletions src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
use BaBeuloula\CdnPhp\Http\HttpFetcher;
use BaBeuloula\CdnPhp\Processor\ImageProcessor;
use BaBeuloula\CdnPhp\Processor\StaticAssetProcessor;
use BaBeuloula\CdnPhp\Security\SsrfValidator;
use BaBeuloula\CdnPhp\Security\UrlSigner;
use BaBeuloula\CdnPhp\Storage\Storage;
use Bref\Logger\StderrLogger as BrefLogger;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
Expand Down Expand Up @@ -57,6 +59,9 @@ public function boot(): void

$this->add(self::KEY_FORCE_TOKEN, $this->getEnv('FORCE_TOKEN') ?? '');

$signatureSecret = $this->getEnv('SIGNATURE_SECRET') ?? '';
$urlSigner = ('' !== $signatureSecret) ? new UrlSigner($signatureSecret) : null;

$this->add(
Cdn::class,
new Cdn(
Expand All @@ -68,6 +73,7 @@ public function boot(): void
$this->get(Cache::class),
$this->get(LoggerInterface::class),
$this->get(self::KEY_FORCE_TOKEN),
$urlSigner,
),
);
}
Expand Down Expand Up @@ -141,12 +147,14 @@ private function bootHttpFetcher(): void
self::KEY_FETCH_ALLOW_REDIRECTS,
true === filter_var($this->getEnv('FETCH_ALLOW_REDIRECTS') ?? '0', FILTER_VALIDATE_BOOLEAN),
);
$this->add(SsrfValidator::class, new SsrfValidator());
$this->add(
HttpFetcher::class,
new HttpFetcher(
$this->get(self::KEY_FETCH_TIMEOUT),
$this->get(self::KEY_FETCH_MAX_SIZE),
$this->get(self::KEY_FETCH_ALLOW_REDIRECTS),
$this->get(SsrfValidator::class),
),
);
}
Expand Down
18 changes: 18 additions & 0 deletions src/Exception/CdnException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

/**
* @author BaBeuloula <info@babeuloula.fr>
* @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\Exception;

abstract class CdnException extends \RuntimeException
{
}
2 changes: 1 addition & 1 deletion src/Exception/EmptyUriException.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

use Symfony\Component\HttpFoundation\Response;

class EmptyUriException extends \LogicException
class EmptyUriException extends CdnException
{
public function __construct()
{
Expand Down
Loading
Loading