diff --git a/.env b/.env index ce8287c..b2a203f 100644 --- a/.env +++ b/.env @@ -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= diff --git a/README.md b/README.md index aa4811e..fc49519 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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=) +FORCE_TOKEN= ``` ## Running with Docker @@ -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= +``` ## Running Tests diff --git a/phpunit.xml b/phpunit.xml index 0379373..7efd713 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -16,7 +16,8 @@ failOnPhpunitNotice="true" > - + + @@ -31,7 +32,7 @@ src/Flysystem/Adapter/UrlFilesystemAdapter.php - src/ContainerConfig.php + src/Http/HttpFetcher.php diff --git a/public/index.php b/public/index.php index 6269062..2cb4528 100644 --- a/public/index.php +++ b/public/index.php @@ -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); } }; diff --git a/serverless.yml b/serverless.yml index 8ea5ea5..fc9193f 100644 --- a/serverless.yml +++ b/serverless.yml @@ -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 @@ -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 @@ -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} diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 8a891d6..fe2ddd8 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -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(); @@ -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 }, ); diff --git a/src/Cdn.php b/src/Cdn.php index 2e21ce4..26d889a 100644 --- a/src/Cdn.php +++ b/src/Cdn.php @@ -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; @@ -40,6 +41,7 @@ public function __construct( private readonly ImageProcessor $imageProcessor, private readonly Cache $cache, private readonly LoggerInterface $logger, + private readonly string $forceToken = '', ) { } @@ -61,12 +63,10 @@ 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) { @@ -74,20 +74,39 @@ public function handleRequest(Request $request): Response } 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 @@ -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); + } + } } } diff --git a/src/Container.php b/src/Container.php index 48f4228..a631277 100644 --- a/src/Container.php +++ b/src/Container.php @@ -16,6 +16,7 @@ use Aws\S3\S3Client; use BaBeuloula\CdnPhp\Cache\Cache; use BaBeuloula\CdnPhp\Flysystem\Adapter\UrlFilesystemAdapter; +use BaBeuloula\CdnPhp\Http\HttpFetcher; use BaBeuloula\CdnPhp\Processor\ImageProcessor; use BaBeuloula\CdnPhp\Storage\Storage; use Bref\Logger\StderrLogger as BrefLogger; @@ -26,32 +27,44 @@ use League\Flysystem\Local\LocalFilesystemAdapter; use League\Flysystem\Visibility; use Psr\Log\LoggerInterface; -use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem; final class Container { + private const string KEY_ALLOWED_DOMAINS = 'allowed_domains'; + private const string KEY_DOMAINS_ALIASES = 'domains_aliases'; + private const string KEY_STORAGE_DRIVER = 'storage_driver'; + private const string KEY_STORAGE_PATH = 'storage_path'; + private const string KEY_CACHE_TTL = 'cache_ttl'; + private const string KEY_IMAGE_COMPRESSION = 'image_compression'; + private const string KEY_FETCH_TIMEOUT = 'fetch_timeout'; + private const string KEY_FETCH_MAX_SIZE = 'fetch_max_size'; + private const string KEY_FETCH_ALLOW_REDIRECTS = 'fetch_allow_redirects'; + private const string KEY_FORCE_TOKEN = 'force_token'; + /** @var array */ private array $container = []; public function boot(): void { - $this->add(SymfonyFilesystem::class, new SymfonyFilesystem()); - $this->bootDomains(); $this->bootLogger(); + $this->bootHttpFetcher(); $this->bootStorage(); $this->bootImageProcessor(); $this->bootCache(); + $this->add(self::KEY_FORCE_TOKEN, $this->getEnv('FORCE_TOKEN') ?? ''); + $this->add( Cdn::class, new Cdn( - $this->get('allowed_domains'), - $this->get('domains_aliases'), + $this->get(self::KEY_ALLOWED_DOMAINS), + $this->get(self::KEY_DOMAINS_ALIASES), $this->get(Storage::class), $this->get(ImageProcessor::class), $this->get(Cache::class), $this->get(LoggerInterface::class), + $this->get(self::KEY_FORCE_TOKEN), ), ); } @@ -74,7 +87,7 @@ public function get(string $key): mixed return $this->container[$key]; } - private function getEnv(string $key, mixed $default = null): string + private function getEnv(string $key, ?string $default = null): ?string { // phpcs:ignore if (false === \array_key_exists($key, $_ENV)) { @@ -90,8 +103,8 @@ private function bootLogger(): void $this->add( LoggerInterface::class, new BrefLogger( - $this->getEnv('LOG_LEVEL'), - $this->getEnv('LOG_STREAM'), + $this->getEnv('LOG_LEVEL') ?? 'debug', + $this->getEnv('LOG_STREAM') ?? 'php://stderr', ), ); } @@ -99,31 +112,54 @@ private function bootLogger(): void private function bootDomains(): void { $domainsAliases = []; - foreach (explode(',', $this->getEnv('DOMAINS_ALIASES')) as $domain) { - if (false === str_contains($domain, '=')) { - throw new \InvalidArgumentException("Domain alias must contain '='."); + foreach (explode(',', $this->getEnv('DOMAINS_ALIASES') ?? '') as $domain) { + $domain = trim($domain); + if ('' === $domain) { + continue; } - $parts = explode('=', $domain); - $domainsAliases[$parts[1]] = $parts[0]; + $parts = explode('=', $domain, 2); + if (2 !== \count($parts)) { + throw new \InvalidArgumentException("Domain alias '{$domain}' must use the format 'alias=domain'."); + } + + $domainsAliases[trim($parts[1])] = trim($parts[0]); } - $this->add('domains_aliases', $domainsAliases); + $this->add(self::KEY_DOMAINS_ALIASES, $domainsAliases); + + $this->add(self::KEY_ALLOWED_DOMAINS, explode(',', $this->getEnv('ALLOWED_DOMAINS') ?? '')); + } - $this->add('allowed_domains', explode(',', $this->getEnv('ALLOWED_DOMAINS'))); + private function bootHttpFetcher(): void + { + $this->add(self::KEY_FETCH_TIMEOUT, (int) ($this->getEnv('FETCH_TIMEOUT') ?? '10')); + $this->add(self::KEY_FETCH_MAX_SIZE, (int) ($this->getEnv('FETCH_MAX_SIZE') ?? '52428800')); + $this->add( + self::KEY_FETCH_ALLOW_REDIRECTS, + true === filter_var($this->getEnv('FETCH_ALLOW_REDIRECTS') ?? '0', FILTER_VALIDATE_BOOLEAN), + ); + $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), + ), + ); } private function bootStorage(): void { - $this->add('storage_driver', $this->getEnv('STORAGE_DRIVER')); - $this->add('storage_path', $this->getEnv('STORAGE_PATH')); - switch ($this->get('storage_driver')) { + $this->add(self::KEY_STORAGE_DRIVER, $this->getEnv('STORAGE_DRIVER')); + $this->add(self::KEY_STORAGE_PATH, $this->getEnv('STORAGE_PATH')); + switch ($this->get(self::KEY_STORAGE_DRIVER)) { case 's3': $awsClient = new S3Client( [ 'version' => $this->getEnv('S3_VERSION', 'latest'), 'region' => $this->getEnv('S3_REGION'), 'endpoint' => $this->getEnv('S3_ENDPOINT'), - 'use_path_style_endpoint' => 1 === ((int) $this->getEnv('S3_PATH_STYLE_ENDPOINT', 1)), + 'use_path_style_endpoint' => 1 === ((int) $this->getEnv('S3_PATH_STYLE_ENDPOINT', '1')), 'credentials' => [ 'key' => $this->getEnv('S3_ACCESS_KEY'), 'secret' => $this->getEnv('S3_SECRET_KEY'), @@ -135,7 +171,7 @@ private function bootStorage(): void FilesystemAdapter::class, new AwsS3V3Adapter( client: $awsClient, - bucket: $this->getEnv('S3_BUCKET'), + bucket: $this->getEnv('S3_BUCKET') ?? '', visibility: new PortableVisibilityConverter(Visibility::PRIVATE), ), ); @@ -145,19 +181,20 @@ private function bootStorage(): void $this->add( FilesystemAdapter::class, new LocalFilesystemAdapter( - $this->get('storage_path'), + $this->get(self::KEY_STORAGE_PATH), ), ); break; default: - throw new \InvalidArgumentException("Unsupported storage driver '{$this->get('storage_driver')}'."); + $driver = $this->get(self::KEY_STORAGE_DRIVER); + throw new \InvalidArgumentException("Unsupported storage driver '{$driver}'."); } $this->add( UrlFilesystemAdapter::class, new UrlFilesystemAdapter( - $this->get(SymfonyFilesystem::class), + $this->get(HttpFetcher::class), ), ); $this->add(LeagueFilesystem::class, new LeagueFilesystem($this->get(FilesystemAdapter::class))); @@ -165,7 +202,7 @@ private function bootStorage(): void Storage::class, new Storage( $this->get(LeagueFilesystem::class), - $this->get(SymfonyFilesystem::class), + $this->get(HttpFetcher::class), $this->get(LoggerInterface::class), ), ); @@ -173,26 +210,26 @@ private function bootStorage(): void private function bootCache(): void { - $this->add('cache_ttl', (int) $this->getEnv('CACHE_TTL')); + $this->add(self::KEY_CACHE_TTL, (int) $this->getEnv('CACHE_TTL')); $this->add( Cache::class, new Cache( $this->get(Storage::class), - $this->get('cache_ttl'), + $this->get(self::KEY_CACHE_TTL), ), ); } private function bootImageProcessor(): void { - $this->add('image_compression', (int) $this->getEnv('IMAGE_COMPRESSION')); + $this->add(self::KEY_IMAGE_COMPRESSION, (int) $this->getEnv('IMAGE_COMPRESSION')); $this->add( ImageProcessor::class, new ImageProcessor( $this->get(FilesystemAdapter::class), $this->get(UrlFilesystemAdapter::class), $this->get(LoggerInterface::class), - $this->get('image_compression'), + $this->get(self::KEY_IMAGE_COMPRESSION), ), ); } diff --git a/src/Dto/QueryParams.php b/src/Dto/QueryParams.php index 64642c7..70c536c 100644 --- a/src/Dto/QueryParams.php +++ b/src/Dto/QueryParams.php @@ -17,6 +17,14 @@ final class QueryParams { + public const string PARAM_WIDTH = 'w'; + public const string PARAM_HEIGHT = 'h'; + public const string PARAM_WATERMARK_URL = 'wu'; + public const string PARAM_WATERMARK_POSITION = 'wp'; + public const string PARAM_WATERMARK_SIZE = 'ws'; + public const string PARAM_WATERMARK_OPACITY = 'wo'; + public const int MAX_DIMENSION = 5000; + public readonly ?string $watermarkUrl; public readonly int $watermarkSize; public readonly int $watermarkOpacity; @@ -59,12 +67,12 @@ public static function fromArray(array $query): QueryParams { // phpcs:disable return new self( - empty($query['w']) ? 0 : ((int) $query['w']), - empty($query['h']) ? null : ((int) $query['h']), - empty($query['wu']) ? null : ((string) $query['wu']), - empty($query['wp']) ? WatermarkPosition::default() : (WatermarkPosition::tryFrom($query['wp']) ?? WatermarkPosition::default()), - empty($query['ws']) ? 75 : ((int) $query['ws']), - empty($query['wo']) ? 50 : ((int) $query['wo']), + empty($query[self::PARAM_WIDTH]) ? 0 : min((int) $query[self::PARAM_WIDTH], self::MAX_DIMENSION), + empty($query[self::PARAM_HEIGHT]) ? null : min((int) $query[self::PARAM_HEIGHT], self::MAX_DIMENSION), + empty($query[self::PARAM_WATERMARK_URL]) ? null : ((string) $query[self::PARAM_WATERMARK_URL]), + 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]), ); // phpcs:enable } @@ -73,8 +81,8 @@ public static function fromArray(array $query): QueryParams public function toArray(): array { $params = [ - 'w' => $this->width, - 'h' => $this->height, + self::PARAM_WIDTH => $this->width, + self::PARAM_HEIGHT => $this->height, 'fit' => 'max', ]; diff --git a/src/Exception/FileTooLargeException.php b/src/Exception/FileTooLargeException.php new file mode 100644 index 0000000..476e3e6 --- /dev/null +++ b/src/Exception/FileTooLargeException.php @@ -0,0 +1,27 @@ + + * @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; + +use Symfony\Component\HttpFoundation\Response; + +class FileTooLargeException extends \Exception +{ + public function __construct(string $url, int $maxBytes) + { + parent::__construct( + sprintf('File at %s exceeds the maximum allowed size of %d bytes.', $url, $maxBytes), + code: Response::HTTP_REQUEST_ENTITY_TOO_LARGE, + ); + } +} diff --git a/src/Flysystem/Adapter/UrlFilesystemAdapter.php b/src/Flysystem/Adapter/UrlFilesystemAdapter.php index 8d5738f..7967890 100644 --- a/src/Flysystem/Adapter/UrlFilesystemAdapter.php +++ b/src/Flysystem/Adapter/UrlFilesystemAdapter.php @@ -13,22 +13,25 @@ namespace BaBeuloula\CdnPhp\Flysystem\Adapter; +use BaBeuloula\CdnPhp\Http\HttpFetcher; use League\Flysystem\Config; use League\Flysystem\FileAttributes; use League\Flysystem\FilesystemAdapter; -use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem; final class UrlFilesystemAdapter implements FilesystemAdapter { - public function __construct(private readonly SymfonyFilesystem $symfonyFilesystem) + public function __construct(private readonly HttpFetcher $httpFetcher) { } public function fileExists(string $path): bool { - $path = 'https://' . $path; - - return str_contains(get_headers($path)[0] ?? '', '200 OK'); + try { + $this->httpFetcher->fetch('https://' . $path); + return true; + } catch (\RuntimeException) { + return false; + } } public function directoryExists(string $path): bool @@ -48,7 +51,7 @@ public function writeStream(string $path, $contents, Config $config): void public function read(string $path): string { - return $this->symfonyFilesystem->readFile('https://' . $path); + return $this->httpFetcher->fetch('https://' . $path); } public function readStream(string $path) diff --git a/src/Http/HttpFetcher.php b/src/Http/HttpFetcher.php new file mode 100644 index 0000000..6b99816 --- /dev/null +++ b/src/Http/HttpFetcher.php @@ -0,0 +1,62 @@ + + * @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\Http; + +use BaBeuloula\CdnPhp\Exception\FileTooLargeException; + +class HttpFetcher +{ + public function __construct( + private readonly int $timeout, + private readonly int $maxBytes, + private readonly bool $allowRedirects = false, + ) { + } + + /** + * @throws \RuntimeException + * @throws FileTooLargeException + */ + public function fetch(string $url): string + { + $followLocation = (true === $this->allowRedirects) ? 1 : 0; + $context = stream_context_create( + [ + 'http' => [ + 'timeout' => $this->timeout, + 'follow_location' => $followLocation, + 'max_redirects' => (true === $this->allowRedirects) ? 5 : 0, + ], + 'https' => ['timeout' => $this->timeout], + ], + ); + + set_error_handler(static fn (): bool => true); + try { + $content = file_get_contents($url, false, $context, offset: 0, length: max(1, $this->maxBytes + 1)); + } finally { + restore_error_handler(); + } + + if (false === $content) { + throw new \RuntimeException("Failed to fetch URL: {$url}"); + } + + if (\strlen($content) > $this->maxBytes) { + throw new FileTooLargeException($url, $this->maxBytes); + } + + return $content; + } +} diff --git a/src/Processor/PathProcessor.php b/src/Processor/PathProcessor.php index 9ff80ed..b9e17dc 100644 --- a/src/Processor/PathProcessor.php +++ b/src/Processor/PathProcessor.php @@ -30,21 +30,6 @@ public function getPath(bool $supportWebp = false): string return $this->path . ((true === $supportWebp) ? '.webp' : ''); } - /** - * @param mixed[] $array - * - * @return mixed[] - */ - private function arrayMapAssoc(callable $callback, array $array): array - { - return array_map( - static function (mixed $key) use ($callback, $array) { - return $callback($key, $array[$key]); - }, - array_keys($array), - ); - } - private function generatePath(): void { $params = $this->decoder->getParams()->toArray(); @@ -54,7 +39,11 @@ private function generatePath(): void $params['mark'] = (new AsciiSlugger())->slug($this->decoder->getParams()->watermarkUrl)->toString(); } - $path = implode('/', $this->arrayMapAssoc(static fn ($k, $v) => "$k$v", $params)); + $parts = []; + foreach ($params as $key => $value) { + $parts[] = "{$key}{$value}"; + } + $path = implode('/', $parts); $extension = pathinfo($this->decoder->getImageUrl(), PATHINFO_EXTENSION); $filename = md5($this->decoder->getImageUrl()) . '.' . $extension; diff --git a/src/Storage/Storage.php b/src/Storage/Storage.php index d829cd2..2f7644c 100644 --- a/src/Storage/Storage.php +++ b/src/Storage/Storage.php @@ -13,52 +13,44 @@ namespace BaBeuloula\CdnPhp\Storage; -use BaBeuloula\CdnPhp\Decoder\UriDecoder; use BaBeuloula\CdnPhp\Exception\FileNotFoundException; +use BaBeuloula\CdnPhp\Exception\FileTooLargeException; +use BaBeuloula\CdnPhp\Http\HttpFetcher; use League\Flysystem\Filesystem; use Psr\Log\LoggerInterface; -use Symfony\Component\Filesystem\Exception\IOException; -use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem; final class Storage { - private UriDecoder $decoder; - public function __construct( private readonly Filesystem $filesystem, - private readonly SymfonyFilesystem $symfonyFilesystem, + private readonly HttpFetcher $httpFetcher, private readonly LoggerInterface $logger, ) { } - public function setDecoder(UriDecoder $decoder): self - { - $this->decoder = $decoder; - - return $this; - } - - public function fetchImage(string $imageUrl, bool $force = false): string + public function fetchImage(string $imageUrl, string $domain, bool $force = false): string { - $this->logger->info('Fetching image: {imageUrl}', ['imageUrl' => $imageUrl]); + $this->logger->debug('Fetching image: {imageUrl}', ['imageUrl' => $imageUrl]); $extension = pathinfo($imageUrl, PATHINFO_EXTENSION); $filename = md5($imageUrl) . '.' . $extension; $path = sprintf( '%s/original/%s', - $this->decoder->getDomain(), + $domain, $filename, ); if (true === $this->exists($path) && false === $force) { - $this->logger->info('Original image already saved: {path}', ['path' => $path]); + $this->logger->debug('Original image already saved: {path}', ['path' => $path]); return $path; } try { - $content = $this->symfonyFilesystem->readFile($imageUrl); - } catch (IOException $e) { + $content = $this->httpFetcher->fetch($imageUrl); + } catch (FileTooLargeException $e) { + throw $e; + } catch (\RuntimeException $e) { throw new FileNotFoundException($imageUrl, $e); } @@ -95,7 +87,7 @@ public function lastModified(string $path): int public function save(string $path, string $content): void { - $this->logger->info('Save image on storage: {path}', ['path' => $path]); + $this->logger->debug('Save image on storage: {path}', ['path' => $path]); $this->filesystem->write($path, $content); } diff --git a/tests/Cache/CacheTest.php b/tests/Cache/CacheTest.php index bae8e54..610348d 100644 --- a/tests/Cache/CacheTest.php +++ b/tests/Cache/CacheTest.php @@ -53,6 +53,8 @@ public function canCreateAResponseWithoutWebpSupport(): void static::assertGreaterThan(0, $response->headers->get('Content-Length')); static::assertSame('max-age=' . $defaultTtl . ', public', $response->headers->get('Cache-Control')); static::assertNotNull($response->headers->get('Last-Modified')); + static::assertSame('Accept', $response->headers->get('Vary')); + static::assertSame('nosniff', $response->headers->get('X-Content-Type-Options')); } #[Test] @@ -65,4 +67,21 @@ public function canCreateAResponseWithWebpSupport(): void static::assertSame('image/webp', $response->headers->get('Content-Type')); } + + #[Test] + public function canCreateANotModifiedResponse(): void + { + /** @var Cache $cache */ + $cache = $this->getContainer(Cache::class); + + $firstResponse = $cache->createResponse(static::TEST_FILENAME, 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); + + static::assertSame(Response::HTTP_NOT_MODIFIED, $response->getStatusCode()); + } } diff --git a/tests/Cdn/CdnTest.php b/tests/Cdn/CdnTest.php index 35490e0..718f780 100644 --- a/tests/Cdn/CdnTest.php +++ b/tests/Cdn/CdnTest.php @@ -116,16 +116,96 @@ public function canHandleRequest(): void static::assertSame(Response::HTTP_OK, $response->getStatusCode()); } + #[Test] + public function canHandleRequestWithAllowedWatermarkDomain(): void + { + // Watermark from an allowed domain must not be blocked + $params = http_build_query(['wu' => static::TEST_WATERMARK_URL]); + $request = Request::create('http://mycdn.com/' . static::TEST_BASE_URI . '?' . $params); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + #[Test] + public function canHandleRequestWithUppercaseExtension(): void + { + // Extension check must be case-insensitive (.JPG treated like .jpg) + $request = Request::create('http://mycdn.com/https://example.com/image.JPG'); + + $response = $this->cdn->handleRequest($request); + + // 404 (image not mocked), not 400 (unsupported extension) + static::assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + } + + #[Test] + public function cantHandleRequestWithNonHttpScheme(): void + { + // ftp:// is not stripped by UriDecoder, so the parsed domain becomes "ftp" → not in allowedDomains + $request = Request::create('http://mycdn.com/ftp://example.com/image.jpg'); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + } + + #[Test] + public function cantHandleRequestWithNotAllowedWatermarkDomain(): void + { + $params = http_build_query(['wu' => 'https://not-allowed.com/watermark.jpg']); + $request = Request::create('http://mycdn.com/' . static::TEST_BASE_URI . '?' . $params); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + } + #[Test] public function canHandleRequestAndForceReFetch(): void { - $request = Request::create( - 'http://mycdn.com/' . static::TEST_BASE_URI . '?' . http_build_query(['force' => true]) - ); + $params = http_build_query(['force' => true, 'token' => static::TEST_FORCE_TOKEN]); + $request = Request::create('http://mycdn.com/' . static::TEST_BASE_URI . '?' . $params); $response = $this->cdn->handleRequest($request); static::assertSame('image/jpeg', $response->headers->get('Content-Type')); static::assertSame(Response::HTTP_OK, $response->getStatusCode()); } + + #[Test] + public function cantForceReFetchWithWrongToken(): void + { + // Pre-populate cache + $this->cdn->handleRequest(Request::create('http://mycdn.com/' . static::TEST_BASE_URI)); + + // Force with wrong token is silently ignored + $params = http_build_query(['force' => true, 'token' => 'wrong-token']); + $response = $this->cdn->handleRequest( + Request::create('http://mycdn.com/' . static::TEST_BASE_URI . '?' . $params) + ); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + #[Test] + public function cantHandleRequestWithTooLargeImage(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_TOO_LARGE_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_REQUEST_ENTITY_TOO_LARGE, $response->getStatusCode()); + } + + #[Test] + public function cantHandleRequestWithCorruptImage(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_CORRUPT_IMAGE_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + } } diff --git a/tests/Decoder/UriDecoderTest.php b/tests/Decoder/UriDecoderTest.php index f4e3841..92da9d8 100644 --- a/tests/Decoder/UriDecoderTest.php +++ b/tests/Decoder/UriDecoderTest.php @@ -78,4 +78,26 @@ public function canGetQueryParams(): void static::assertInstanceOf(QueryParams::class, $decoder->getParams()); } + + #[Test] + public function canHandleUnknownAlias(): void + { + // An alias not present in the map results in an invalid URL (domain replaced by empty string) + $decoder = new UriDecoder( + '_nonexistent_/example.com/image.jpg', + ['other_alias' => 'example.com'], + ); + + static::assertStringStartsWith('https://', $decoder->getUri()); + static::assertStringNotContainsString('_nonexistent_', $decoder->getUri()); + } + + #[Test] + public function canHandleNonHttpScheme(): void + { + // ftp:// is not stripped, so it ends up wrapped in https:// and will fail URL validation + $decoder = new UriDecoder('ftp://example.com/image.jpg'); + + static::assertSame('https://ftp://example.com/image.jpg', $decoder->getUri()); + } } diff --git a/tests/Dto/QueryParamsTest.php b/tests/Dto/QueryParamsTest.php index 63a33b4..7b46a95 100644 --- a/tests/Dto/QueryParamsTest.php +++ b/tests/Dto/QueryParamsTest.php @@ -38,6 +38,36 @@ public function canNormalizeWatermarkOpacity(): void static::assertEquals(50, QueryParams::fromArray([])->watermarkOpacity); } + #[Test] + public function canClampDimensions(): void + { + $params = QueryParams::fromArray(['w' => 99999, 'h' => 99999]); + static::assertSame(QueryParams::MAX_DIMENSION, $params->width); + static::assertSame(QueryParams::MAX_DIMENSION, $params->height); + } + + #[Test] + public function canFitMaxWhenWidthIsZeroWithHeight(): void + { + // fit='crop' requires BOTH width > 0 AND height set; width=0 must stay 'max' + $array = QueryParams::fromArray(['w' => 0, 'h' => 100])->toArray(); + static::assertSame('max', $array['fit']); + } + + #[Test] + public function canFallbackToDefaultWatermarkPosition(): void + { + $params = QueryParams::fromArray(['wu' => 'example.com/wm.jpg', 'wp' => 'invalid-position']); + static::assertSame(WatermarkPosition::default(), $params->watermarkPosition); + } + + #[Test] + public function canParseWatermarkUrlWithPort(): void + { + $params = QueryParams::fromArray(['wu' => 'http://example.com:8080/watermark.jpg']); + static::assertSame('example.com:8080/watermark.jpg', $params->watermarkUrl); + } + #[Test] public function canConvertToAnArray(): void { diff --git a/tests/Exception/FileTooLargeExceptionTest.php b/tests/Exception/FileTooLargeExceptionTest.php new file mode 100644 index 0000000..cb9f314 --- /dev/null +++ b/tests/Exception/FileTooLargeExceptionTest.php @@ -0,0 +1,32 @@ + + * @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\Exception; + +use BaBeuloula\CdnPhp\Exception\FileTooLargeException; +use BaBeuloula\CdnPhp\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; +use Symfony\Component\HttpFoundation\Response; + +class FileTooLargeExceptionTest extends TestCase +{ + #[Test] + public function canCreateException(): void + { + $exception = new FileTooLargeException('https://example.com/big.jpg', 1024); + + static::assertSame(Response::HTTP_REQUEST_ENTITY_TOO_LARGE, $exception->getCode()); + static::assertStringContainsString('https://example.com/big.jpg', $exception->getMessage()); + static::assertStringContainsString('1024', $exception->getMessage()); + } +} diff --git a/tests/Processor/PathProcessorTest.php b/tests/Processor/PathProcessorTest.php index 370cf7d..1312930 100644 --- a/tests/Processor/PathProcessorTest.php +++ b/tests/Processor/PathProcessorTest.php @@ -65,4 +65,21 @@ public static function queryParametersProvider(): \Generator yield ['w100/h100', ['w' => 100, 'h' => 100]]; } + + #[Test] + public function canGetPathWithDifferentExtensions(): void + { + foreach (['png', 'gif', 'webp'] as $ext) { + $imageUri = "https://example.com/image.{$ext}"; + $decoder = new UriDecoder($imageUri); + $pathProcessor = new PathProcessor($decoder); + + $expectedFilename = md5($imageUri) . ".{$ext}"; + static::assertSame( + static::TEST_DOMAIN . '/w0/' . $expectedFilename, + $pathProcessor->getPath(), + "Failed for extension: {$ext}", + ); + } + } } diff --git a/tests/Storage/StorageTest.php b/tests/Storage/StorageTest.php index f0e2b3d..d2c10c4 100644 --- a/tests/Storage/StorageTest.php +++ b/tests/Storage/StorageTest.php @@ -32,7 +32,6 @@ protected function setUp(): void /** @var Storage $storage */ $storage = $this->getContainer(Storage::class); - $storage->setDecoder($this->decoder); $this->storage = $storage; } @@ -43,7 +42,7 @@ public function canFetchAnImage(): void { static::assertSame( static::TEST_ORIGINAL_PATH, - $this->storage->fetchImage($this->decoder->getImageUrl()), + $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain()), ); } @@ -54,10 +53,20 @@ public function canFetchAnExistingImage(): void static::assertSame( static::TEST_ORIGINAL_PATH, - $this->storage->fetchImage($this->decoder->getImageUrl()), + $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain()), ); } + #[Test] + public function canForceFetchAnAlreadyCachedImage(): void + { + $this->storage->save(static::TEST_ORIGINAL_PATH, 'stale_content'); + + $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain(), true); + + static::assertNotSame('stale_content', $this->storage->read(static::TEST_ORIGINAL_PATH)); + } + #[Test] public function cantFetchANotfoundImage(): void { @@ -65,16 +74,15 @@ public function cantFetchANotfoundImage(): void /** @var Storage $storage */ $storage = $this->getContainer(Storage::class); - $storage->setDecoder($decoder); static::expectException(FileNotFoundException::class); - $storage->fetchImage($decoder->getImageUrl()); + $storage->fetchImage($decoder->getImageUrl(), $decoder->getDomain()); } #[Test] public function canReadAnImage(): void { - $this->storage->fetchImage($this->decoder->getImageUrl()); + $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain()); static::assertIsString($this->storage->read(static::TEST_ORIGINAL_PATH)); } @@ -82,7 +90,7 @@ public function canReadAnImage(): void #[Test] public function canReadAsStreamAnImage(): void { - $this->storage->fetchImage($this->decoder->getImageUrl()); + $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain()); static::assertIsResource($this->storage->readStream(static::TEST_ORIGINAL_PATH)); } @@ -90,7 +98,7 @@ public function canReadAsStreamAnImage(): void #[Test] public function canGetMimetypeOfAnImage(): void { - $this->storage->fetchImage($this->decoder->getImageUrl()); + $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain()); static::assertSame( 'image/jpeg', @@ -101,7 +109,7 @@ public function canGetMimetypeOfAnImage(): void #[Test] public function canGetFilesizeOfAnImage(): void { - $this->storage->fetchImage($this->decoder->getImageUrl()); + $this->storage->fetchImage($this->decoder->getImageUrl(), $this->decoder->getDomain()); static::assertGreaterThan(0, $this->storage->fileSize(static::TEST_ORIGINAL_PATH)); } @@ -109,7 +117,7 @@ public function canGetFilesizeOfAnImage(): void #[Test] public function canGetLastModifiedOfAnImage(): void { - $this->storage->fetchImage($this->decoder->getImageUrl()); + $this->storage->fetchImage($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 e3aca0d..afde3a5 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -14,17 +14,20 @@ namespace BaBeuloula\CdnPhp\Tests; use BaBeuloula\CdnPhp\Container; +use BaBeuloula\CdnPhp\Exception\FileTooLargeException; +use BaBeuloula\CdnPhp\Http\HttpFetcher; use League\Flysystem\FilesystemAdapter; use League\Flysystem\InMemory\InMemoryFilesystemAdapter; use PHPUnit\Framework\TestCase as BaseTestCase; -use Symfony\Component\Filesystem\Exception\IOException; -use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem; class TestCase extends BaseTestCase { protected const string TEST_BASE_URI = 'https://example.com/image.jpg'; protected const string TEST_BASE_URI_ALIAS = '_ex_ample_/image.jpg'; protected const string TEST_WATERMARK_URL = 'https://example.com/watermark.jpg'; + protected const string TEST_TOO_LARGE_URI = 'https://example.com/too-large.jpg'; + protected const string TEST_CORRUPT_IMAGE_URI = 'https://example.com/corrupt.jpg'; + protected const string TEST_FORCE_TOKEN = 'test-force-token'; protected const string TEST_DOMAIN = 'example.com'; protected const string TEST_DOMAIN_ALIAS = 'ex_ample'; protected const string TEST_FILENAME = 'image.jpg'; @@ -39,23 +42,31 @@ protected function setUp(): void { parent::setUp(); - $symfonyFilesystemMock = $this->createStub(SymfonyFilesystem::class); + $httpFetcherMock = $this->createStub(HttpFetcher::class); - $symfonyFilesystemMock - ->method('readFile') + $httpFetcherMock + ->method('fetch') ->willReturnCallback( - static function (string $path) { - if (static::TEST_BASE_URI === $path) { + static function (string $url) { + if (static::TEST_BASE_URI === $url) { return static::getTestImageContent(); } - throw new IOException('Testing error.'); + if (static::TEST_CORRUPT_IMAGE_URI === $url) { + return 'not-an-image-data'; + } + + if (static::TEST_TOO_LARGE_URI === $url) { + throw new FileTooLargeException($url, 1); + } + + throw new \RuntimeException("URL not mocked: {$url}"); }, ) ; $this->container = new Container(); - $this->container->add(SymfonyFilesystem::class, $symfonyFilesystemMock); + $this->container->add(HttpFetcher::class, $httpFetcherMock); $this->container->add(FilesystemAdapter::class, new InMemoryFilesystemAdapter()); $this->container->boot(); }