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
8 changes: 8 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,11 @@ S3_PATH_STYLE_ENDPOINT=1

# Compression
IMAGE_COMPRESSION=75

# HTTP fetch
FETCH_TIMEOUT=10
FETCH_MAX_SIZE=52428800
FETCH_ALLOW_REDIRECTS=0

# Force re-fetch token (empty = no protection)
FORCE_TOKEN=
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ It supports fetching, optimizing, caching and serving images dynamically while e
- `wo` (watermark opacity percentage, default: 50%)
- **Smart Storage Structure:** Images 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.
- **Force Re-fetch Protection:** Optional secret token required to bypass the cache (`FORCE_TOKEN`).

## Serverless

Expand Down Expand Up @@ -77,12 +80,12 @@ APP_DEBUG=0
ALLOWED_DOMAINS=mysite.com,another-site.com
DOMAINS_ALIASES=another-site.com/secret-images=another

STORAGE_TYPE=local # "local" or "s3"
STORAGE_DRIVER=local # "local" or "s3"

# Local storage configuration
STORAGE_PATH=/var/task/.cache/driver/local

# S3 storage configuration (if STORAGE_TYPE=s3)
# S3 storage configuration (if STORAGE_DRIVER=s3)
S3_BUCKET=my-bucket
S3_ENDPOINT=https://s3.amazonaws.com
S3_REGION=fr-par
Expand All @@ -93,11 +96,20 @@ S3_SECRET_KEY=your-secret-key
CACHE_TTL=31536000

# Logging
LOG_STREAM=/srv/.cache/log/cdn-php.log
LOG_LEVEL=debug
LOG_STREAM=php://stderr
LOG_LEVEL=info

# Compression
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)
FETCH_ALLOW_REDIRECTS=0

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

## Running with Docker
Expand Down Expand Up @@ -127,7 +139,21 @@ The CDN will:
- Optimize and compress it
- Convert it to WebP if supported
- Store it based on parameters
- Serve it with proper caching headers
- Serve it with proper caching headers (`Cache-Control`, `ETag`, `Vary: Accept`)

### Force re-fetch

To bypass the cache and re-fetch the source image:

```
https://cdn-php.loc/https://www.mysite.com/image.png?force=true
```

If `FORCE_TOKEN` is configured, the token must be provided:

```
https://cdn-php.loc/https://www.mysite.com/image.png?force=true&token=<your-token>
```

## Running Tests

Expand Down
5 changes: 3 additions & 2 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
failOnPhpunitNotice="true"
>
<php>
<server name="LOG_STREAM" value="/tmp/cdn-php.log" force="true" />
<server name="LOG_STREAM" value="/dev/null" force="true" />
<server name="FORCE_TOKEN" value="test-force-token" force="true" />
</php>

<testsuites>
Expand All @@ -31,7 +32,7 @@
</include>
<exclude>
<file>src/Flysystem/Adapter/UrlFilesystemAdapter.php</file>
<file>src/ContainerConfig.php</file>
<file>src/Http/HttpFetcher.php</file>
</exclude>
</source>
</phpunit>
5 changes: 3 additions & 2 deletions public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@
);

// phpcs:ignore
if (true === filter_var($_ENV['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN)) {
if (true === filter_var($_ENV['APP_DEBUG'] ?? '0', FILTER_VALIDATE_BOOLEAN)) {
Debug::enable();
return ErrorHandler::call(static fn () => throw $exception);
}

return ErrorHandler::call(static fn () => throw $exception);
return new Response('Internal Server Error', Response::HTTP_INTERNAL_SERVER_ERROR);
}
};
6 changes: 6 additions & 0 deletions serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ params:
s3_access_key: ${ssm:/cdn-php/prod/s3-access-key}
s3_secret_key: ${ssm:/cdn-php/prod/s3-secret-key}
image_compression: ${ssm:/cdn-php/prod/image-compression}
force_token: ${ssm:/cdn-php/prod/force-token}

dev:
app_debug: 1
Expand All @@ -28,6 +29,7 @@ params:
s3_access_key: ${ssm:/cdn-php/dev/s3-access-key}
s3_secret_key: ${ssm:/cdn-php/dev/s3-secret-key}
image_compression: ${ssm:/cdn-php/dev/image-compression}
force_token: ${ssm:/cdn-php/dev/force-token}

provider:
name: aws
Expand Down Expand Up @@ -58,6 +60,10 @@ functions:
input:
warmer: true
environment:
FETCH_TIMEOUT: 10
FETCH_MAX_SIZE: 52428800
FORCE_TOKEN: ${param:force_token}
FETCH_ALLOW_REDIRECTS: 0
APP_DEBUG: ${param:app_debug}
CACHE_TTL: ${param:cache_ttl}
STORAGE_DRIVER: ${param:storage_driver}
Expand Down
22 changes: 14 additions & 8 deletions src/Cache/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ public function __construct(

public function createResponse(string $path, bool $supportWebp, Request $request): Response
{
$stream = $this->storage->readStream($path);

$lastModified = (new \DateTimeImmutable())->setTimestamp($this->storage->lastModified($path));

$response = new StreamedResponse();
Expand All @@ -38,21 +36,29 @@ public function createResponse(string $path, bool $supportWebp, Request $request
(true === $supportWebp) ? 'image/webp' : $this->storage->mimeType($path),
);
$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');
$response->setMaxAge($this->ttl);
$response->setExpires((new \DateTimeImmutable())->modify("+$this->ttl seconds"));
$response->setLastModified($lastModified);
$response->setEtag(md5((string) $lastModified->getTimestamp()));
$response->setEtag(
md5($path . ':' . $this->storage->fileSize($path) . ':' . $lastModified->getTimestamp()),
);
$response->isNotModified($request);

$response->setCallback(
static function () use ($stream) {
function () use ($path) {
// @codeCoverageIgnoreStart
if (0 !== ftell($stream)) {
rewind($stream);
$stream = $this->storage->readStream($path);
try {
if (0 !== ftell($stream)) {
rewind($stream);
}
fpassthru($stream);
} finally {
fclose($stream);
}
fpassthru($stream);
fclose($stream);
// @codeCoverageIgnoreEnd
},
);
Expand Down
44 changes: 36 additions & 8 deletions src/Cdn.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use BaBeuloula\CdnPhp\Decoder\UriDecoder;
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;
Expand All @@ -40,6 +41,7 @@ public function __construct(
private readonly ImageProcessor $imageProcessor,
private readonly Cache $cache,
private readonly LoggerInterface $logger,
private readonly string $forceToken = '',
) {
}

Expand All @@ -61,33 +63,50 @@ public function handleRequest(Request $request): Response

$pathProcessor = new PathProcessor($decoder);

$this->storage->setDecoder($decoder);

$supportWebp = (true === str_contains((string) $request->headers->get('Accept'), 'image/webp'));

$cachedPath = $pathProcessor->getPath($supportWebp);
$force = $request->query->getBoolean('force');
$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()]);
}

try {
$originalPath = $this->storage->fetchImage($decoder->getImageUrl(), $force);
} catch (FileNotFoundException $e) {
$originalPath = $this->storage->fetchImage($decoder->getImageUrl(), $decoder->getDomain(), $force);
} catch (FileTooLargeException | FileNotFoundException $e) {
return new Response($e->getMessage(), $e->getCode());
}

$processedImage = $this->imageProcessor->process($originalPath, $decoder->getParams());
$this->storage->save($cachedPath, $this->storage->read($processedImage));
try {
$processedImage = $this->imageProcessor->process($originalPath, $decoder->getParams());
$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->info('Serve the image: {cachedPath}', ['cachedPath' => $cachedPath]);
$this->logger->debug('Serve the image: {cachedPath}', ['cachedPath' => $cachedPath]);

return $this->cache->createResponse($cachedPath, $supportWebp, $request);
}

private function resolveForce(Request $request): bool
{
if (false === $request->query->getBoolean('force')) {
return false;
}
if ('' !== $this->forceToken && $request->query->get('token') !== $this->forceToken) {
return false;
}
return true;
}

/**
* @throws EmptyUriException
* @throws InvalidUriException
Expand All @@ -112,5 +131,14 @@ private function validate(UriDecoder $decoder): void
if (false === \in_array($extension, ['png', 'jpg', 'jpeg', 'gif', 'webp'], true)) {
throw new NotSupportedExtensionException($extension);
}

if (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)
) {
throw new NotAllowedDomainException($decoder->getParams()->watermarkUrl);
}
}
}
}
Loading
Loading