From 061e7dd719110f7ec3f38c69112c687685b80143 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 17 Jun 2026 12:20:13 +0200 Subject: [PATCH 01/11] =?UTF-8?q?feat(imaging):=20ImageEngine=20foundation?= =?UTF-8?q?=20=E2=80=94=20libvips=20fast=20path=20+=20capability=20detecti?= =?UTF-8?q?on=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First increment of the modern image pipeline (#109): - New App\Services\Imaging\ImageEngine: capability-detected encoder. - libvips fast path (jcupitt/vips): shrink-on-load, low memory; emits jpeg/webp/avif/jxl when the build supports them. - capabilities(): detects vips, HEIC/HEIF read, AVIF/JPEG-XL write, and optimizer binaries (jpegoptim/pngquant/cwebp/avifenc). - optional post-encode optimization via proc_open argv-array (no shell). - images:generate tries ImageEngine::encode() first, falling back to the existing Imagick/GD path when vips is unavailable: zero behaviour change on hosts without vips (verified: encode() returns false then fallback). - Hardened orphan-variant deletion with a realpath confinement under public/media (path-traversal guard). Activation needs composer require jcupitt/vips + php-vips/libvips built with heif+aom (AVIF) and libjxl (JPEG-XL). Without them, behaviour is unchanged. HEIC import and next-gen output formats follow on this branch. Refs #109 --- app/Services/Imaging/ImageEngine.php | 249 +++++++++++++++++++++++++++ app/Tasks/ImagesGenerateCommand.php | 49 ++++-- 2 files changed, 287 insertions(+), 11 deletions(-) create mode 100644 app/Services/Imaging/ImageEngine.php diff --git a/app/Services/Imaging/ImageEngine.php b/app/Services/Imaging/ImageEngine.php new file mode 100644 index 00000000..4a64e2ea --- /dev/null +++ b/app/Services/Imaging/ImageEngine.php @@ -0,0 +1,249 @@ +|null Cached capability map. */ + private static ?array $caps = null; + + /** + * Detect available imaging capabilities. Cached per-process. + * + * @return array + */ + public static function capabilities(): array + { + if (self::$caps !== null) { + return self::$caps; + } + + $vips = self::vipsAvailable(); + + self::$caps = [ + 'vips' => $vips, + 'imagick' => class_exists(\Imagick::class), + 'gd' => \function_exists('imagecreatetruecolor'), + // Read support for Apple HEIC/HEIF (iPhone): vips (libheif) or the + // Imagick HEIC delegate. + 'heif_read' => ($vips && self::vipsCanLoad('probe.heic')) || self::imagickSupports('HEIC'), + 'avif_write' => ($vips && self::vipsCanWrite('.avif')) || self::imagickSupports('AVIF'), + 'jxl_write' => $vips && self::vipsCanWrite('.jxl'), + // Post-encode optimizers (used opportunistically). + 'opt_jpegoptim' => self::binaryExists('jpegoptim'), + 'opt_pngquant' => self::binaryExists('pngquant'), + 'opt_cwebp' => self::binaryExists('cwebp'), + 'opt_avifenc' => self::binaryExists('avifenc'), + ]; + + return self::$caps; + } + + /** + * Encode a resized variant via libvips. Returns true on success, false + * when this engine cannot handle the request (caller should fall back to + * its existing Imagick/GD path). + * + * @param string $src Source path (any vips-readable format, incl. HEIC). + * @param string $dest Destination path; extension decides the format. + * @param int $targetW Target width in px (height auto, aspect kept). + * @param string $format 'jpeg' | 'webp' | 'avif' | 'jxl'. + * @param int $quality 1-100. + * @param bool $strip Strip metadata from the variant (privacy). + */ + public static function encode( + string $src, + string $dest, + int $targetW, + string $format, + int $quality, + bool $strip = true + ): bool { + if (!self::vipsAvailable()) { + return false; + } + + $targetW = max(1, $targetW); + $quality = max(1, min(100, $quality)); + + try { + // thumbnail() shrinks on load (decodes only what's needed) — the + // big memory/CPU win over decode-then-resize. + $img = \Jcupitt\Vips\Image::thumbnail($src, $targetW, [ + 'height' => 10_000_000, // unbounded → width drives the resize + 'size' => 'down', // never upscale beyond the source + ]); + + if ($format === 'jpeg' && $img->hasAlpha()) { + $img = $img->flatten(['background' => [255, 255, 255]]); + } + + if (!is_dir(dirname($dest))) { + @mkdir(dirname($dest), 0775, true); + } + $img->writeToFile($dest, self::writeOptions($format, $quality, $strip)); + + if (!is_file($dest) || (int) filesize($dest) === 0) { + return false; + } + + self::optimize($dest, $format); + return true; + } catch (\Throwable $e) { + Logger::warning('ImageEngine: vips encode failed, falling back', [ + 'src' => $src, + 'format' => $format, + 'error' => $e->getMessage(), + ], 'imaging'); + return false; + } + } + + /** + * Best-effort post-encode optimization via an installed CLI optimizer. + * Silent no-op when no suitable binary is present. + */ + public static function optimize(string $path, string $format): void + { + if (!is_file($path)) { + return; + } + $caps = self::capabilities(); + + if (($format === 'jpeg' || $format === 'jpg') && $caps['opt_jpegoptim']) { + self::run(['jpegoptim', '--strip-all', '--quiet', $path]); + } elseif ($format === 'png' && $caps['opt_pngquant']) { + self::run(['pngquant', '--force', '--skip-if-larger', '--output', $path, $path]); + } + // webp/avif are emitted at target quality by the encoder; re-optimizing + // risks double-compression artefacts, so they are intentionally skipped. + } + + // ── internals ──────────────────────────────────────────────────────── + + /** + * @return array + */ + private static function writeOptions(string $format, int $quality, bool $strip): array + { + return match ($format) { + 'jpeg', 'jpg' => ['Q' => $quality, 'strip' => $strip, 'interlace' => true, 'optimize_coding' => true], + 'webp' => ['Q' => $quality, 'strip' => $strip, 'effort' => 4], + 'avif' => ['Q' => $quality, 'strip' => $strip], + 'jxl' => ['Q' => $quality, 'strip' => $strip], + default => ['strip' => $strip], + }; + } + + private static function vipsAvailable(): bool + { + return \extension_loaded('vips') && class_exists(\Jcupitt\Vips\Image::class); + } + + /** Can vips write the given file extension (e.g. '.avif')? Probed once. */ + private static function vipsCanWrite(string $ext): bool + { + try { + // 1×1 black pixel; writeToBuffer throws if the saver isn't built in. + $img = \Jcupitt\Vips\Image::black(1, 1); + $img->writeToBuffer($ext, ['Q' => 50]); + return true; + } catch (\Throwable) { + return false; + } + } + + /** Can vips load the given (representative) filename / format? */ + private static function vipsCanLoad(string $filename): bool + { + try { + // findLoad() returns the loader operation name or throws/returns null + // when no loader is built in for that suffix. + return (string) \Jcupitt\Vips\Image::findLoad($filename) !== ''; + } catch (\Throwable) { + return false; + } + } + + private static function imagickSupports(string $format): bool + { + if (!class_exists(\Imagick::class)) { + return false; + } + try { + $found = \Imagick::queryFormats(strtoupper($format)); + return $found !== []; + } catch (\Throwable) { + return false; + } + } + + /** PATH scan + is_executable — no shell, so no command-injection surface. */ + private static function binaryExists(string $bin): bool + { + $path = (string) getenv('PATH'); + if ($path === '') { + return false; + } + foreach (explode(PATH_SEPARATOR, $path) as $dir) { + if ($dir === '') { + continue; + } + $candidate = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $bin; + if (@is_file($candidate) && @is_executable($candidate)) { + return true; + } + } + return false; + } + + /** + * Run an external command via proc_open with an argv ARRAY (no shell + * interpretation → no command injection). Output is discarded; failures + * are swallowed (optimization is opportunistic). + * + * @param array $argv + */ + private static function run(array $argv): void + { + try { + $descriptors = [ + 1 => ['file', \PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null', 'w'], + 2 => ['file', \PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null', 'w'], + ]; + // nosemgrep: argv-array form bypasses the shell entirely; binary + // names are hardcoded constants and the only dynamic element is a + // filesystem path passed as a discrete argv element (never + // interpolated), so command injection is not possible. + $proc = @proc_open($argv, $descriptors, $pipes); // nosemgrep + if (is_resource($proc)) { + @proc_close($proc); + } + } catch (\Throwable) { + // ignore — never fail a variant over optimization + } + } +} diff --git a/app/Tasks/ImagesGenerateCommand.php b/app/Tasks/ImagesGenerateCommand.php index c4b0d49b..025cb6f3 100644 --- a/app/Tasks/ImagesGenerateCommand.php +++ b/app/Tasks/ImagesGenerateCommand.php @@ -149,22 +149,49 @@ protected function execute(InputInterface $input, OutputInterface $output): int continue; } - // Delete orphan files (exist on disk but not in DB) before regenerating + // Delete orphan files (exist on disk but not in DB) before + // regenerating. Confine the deletion to public/media via + // realpath so a deletion can never escape that directory + // (defence-in-depth path-traversal guard). if ($existsOnDisk && !$existsInDb) { - @unlink($dest); + $mediaRoot = realpath(dirname(__DIR__, 2) . '/public/media'); + $orphanReal = realpath($dest); + if ($mediaRoot !== false && $orphanReal !== false + && str_starts_with($orphanReal, $mediaRoot . DIRECTORY_SEPARATOR)) { + // nosemgrep: $orphanReal is realpath()-resolved and + // verified to live under public/media above, so this + // deletion cannot traverse outside that directory. + @unlink($orphanReal); // nosemgrep + } } $ok = false; - if ($fmt === 'jpg') { - $ok = $this->resizeWithImagickOrGd($src, $dest, (int)$width, 'jpeg', (int)$quality['jpg']); - } elseif ($fmt === 'webp') { - if ($imagickOk) { - $ok = $this->resizeWithImagick($src, $dest, (int)$width, 'webp', (int)$quality['webp']); - } elseif ($gdWebpOk) { - $ok = $this->resizeWithGdWebp($src, $dest, (int)$width, (int)$quality['webp']); + // Fast path (#109): libvips — shrink-on-load, low memory, + // supports AVIF/JPEG-XL/HEIC when the build provides them. + // Returns false when vips is unavailable or cannot handle + // the request; we then fall back to the existing Imagick/GD + // path below (zero behaviour change on hosts without vips). + $engineFmt = $fmt === 'jpg' ? 'jpeg' : $fmt; + $ok = \App\Services\Imaging\ImageEngine::encode( + $src, + $dest, + (int)$width, + $engineFmt, + (int)($quality[$fmt] ?? 82) + ); + + if (!$ok) { + if ($fmt === 'jpg') { + $ok = $this->resizeWithImagickOrGd($src, $dest, (int)$width, 'jpeg', (int)$quality['jpg']); + } elseif ($fmt === 'webp') { + if ($imagickOk) { + $ok = $this->resizeWithImagick($src, $dest, (int)$width, 'webp', (int)$quality['webp']); + } elseif ($gdWebpOk) { + $ok = $this->resizeWithGdWebp($src, $dest, (int)$width, (int)$quality['webp']); + } + } elseif ($fmt === 'avif') { + $ok = $imagickOk && $this->resizeWithImagick($src, $dest, (int)$width, 'avif', (int)$quality['avif']); } - } elseif ($fmt === 'avif') { - $ok = $imagickOk && $this->resizeWithImagick($src, $dest, (int)$width, 'avif', (int)$quality['avif']); } if ($ok) { From 02db9469fa182c1106282b5ff2b4dfa8e574389a Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 17 Jun 2026 12:39:05 +0200 Subject: [PATCH 02/11] feat(imaging): HEIC import, settings-driven JPEG-XL, optimizer wiring, diagnostics (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the modern image pipeline on top of the ImageEngine foundation. Ingestion / UploadService: - Accept HEIC/HEIF uploads, gated on ImageEngine capabilities (heif_read) so we never store an original we can't turn into web variants. ISO-BMFF 'ftyp' magic check; HEIC-aware dimension reading via Imagick pingImage (headers only — decompression-bomb safe) since getimagesize() can't read HEIC. Originals keep .heic/.heif; variants stay jpg/webp/avif. - Wire ImageEngine fast path into resizeWithImagick / resizeWithImagickOrGd (libvips first, Imagick/GD fallback unchanged). Variant generation: - images:generate emits JPEG-XL when enabled in settings AND the build can write it (capability-gated; libvips engine). Order unchanged for the existing avif/webp/jpg. Settings: - image.formats/quality gain a jxl entry (default OFF); admin settings form + controller parse fmt_jxl/q_jxl. Diagnostics: - New "Imaging Engine" panel: active engine, libvips/Imagick/GD, HEIC read, AVIF/JPEG-XL write, detected optimizer binaries. Security (per project policy — fix findings even when pre-existing): - Confined orphan-variant + favicon deletions with realpath guards. - Hardened the images:generate exec (realpath-validated console path, escapeshellarg, app-private reused log under storage/logs). composer.json: suggest jcupitt/vips, ext-vips, ext-imagick, spatie/image-optimizer (all optional; behaviour unchanged without them). Verified end-to-end on a vips-less host: images:generate produced 15/15 variants (avif/webp/jpg) via the Imagick fallback, 0 errors. Refs #109 --- .../Admin/DiagnosticsController.php | 27 +++++ app/Controllers/Admin/SettingsController.php | 2 + app/Services/SettingsService.php | 4 +- app/Services/UploadService.php | 101 +++++++++++++++--- app/Tasks/ImagesGenerateCommand.php | 10 +- app/Views/admin/settings.twig | 11 ++ composer.json | 6 ++ 7 files changed, 144 insertions(+), 17 deletions(-) diff --git a/app/Controllers/Admin/DiagnosticsController.php b/app/Controllers/Admin/DiagnosticsController.php index 9eb665b3..123eb39c 100644 --- a/app/Controllers/Admin/DiagnosticsController.php +++ b/app/Controllers/Admin/DiagnosticsController.php @@ -221,6 +221,33 @@ private function runDiagnostics(): array ] ]; + // Imaging engine capabilities (#109) + $caps = \App\Services\Imaging\ImageEngine::capabilities(); + $engine = $caps['vips'] ? 'libvips' : ($caps['imagick'] ? 'Imagick' : ($caps['gd'] ? 'GD' : 'none')); + $results['imaging'] = [ + 'name' => 'Imaging Engine', + 'status' => ($caps['vips'] || $caps['imagick']) ? 'ok' : 'warning', + 'value' => $engine, + 'message' => $caps['vips'] + ? 'libvips active (fast, low-memory variant generation)' + : ($caps['imagick'] ? 'Imagick active (install php-vips for faster, lower-memory generation)' : 'No capable image engine'), + 'details' => [ + 'Engine' => $engine, + 'libvips' => $caps['vips'] ? 'Yes' : 'No', + 'Imagick' => $caps['imagick'] ? 'Yes' : 'No', + 'GD' => $caps['gd'] ? 'Yes' : 'No', + 'HEIC/HEIF read' => $caps['heif_read'] ? 'Yes' : 'No', + 'AVIF write' => $caps['avif_write'] ? 'Yes' : 'No', + 'JPEG-XL write' => $caps['jxl_write'] ? 'Yes' : 'No', + 'Optimizers' => implode(', ', array_keys(array_filter([ + 'jpegoptim' => $caps['opt_jpegoptim'], + 'pngquant' => $caps['opt_pngquant'], + 'cwebp' => $caps['opt_cwebp'], + 'avifenc' => $caps['opt_avifenc'], + ]))) ?: 'none', + ], + ]; + return $results; } diff --git a/app/Controllers/Admin/SettingsController.php b/app/Controllers/Admin/SettingsController.php index 9825492a..25454313 100644 --- a/app/Controllers/Admin/SettingsController.php +++ b/app/Controllers/Admin/SettingsController.php @@ -81,6 +81,7 @@ public function save(Request $request, Response $response): Response 'avif' => isset($data['fmt_avif']), 'webp' => isset($data['fmt_webp']), 'jpg' => isset($data['fmt_jpg']), + 'jxl' => isset($data['fmt_jxl']), // #109 — emitted only when the build can write JPEG-XL ]; // Invariant: never persist an all-disabled set. With every format off, // generateVariantsForImage() skips all encodes and NO variant can ever be @@ -94,6 +95,7 @@ public function save(Request $request, Response $response): Response 'avif' => max(1, min(100, (int)($data['q_avif'] ?? 50))), 'webp' => max(1, min(100, (int)($data['q_webp'] ?? 75))), 'jpg' => max(1, min(100, (int)($data['q_jpg'] ?? 85))), + 'jxl' => max(1, min(100, (int)($data['q_jxl'] ?? 60))), ]; $preview = [ 'width' => max(64, (int)($data['preview_w'] ?? 480)), diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 879ee524..7fd11ce3 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -117,8 +117,8 @@ public function set(string $key, mixed $value): void public function defaults(): array { return [ - 'image.formats' => ['avif' => true, 'webp' => true, 'jpg' => true], - 'image.quality' => ['avif' => 50, 'webp' => 75, 'jpg' => 85], + 'image.formats' => ['avif' => true, 'webp' => true, 'jpg' => true, 'jxl' => false], + 'image.quality' => ['avif' => 50, 'webp' => 75, 'jpg' => 85, 'jxl' => 60], 'image.breakpoints' => ['sm' => 768, 'md' => 1200, 'lg' => 1920, 'xl' => 2560, 'xxl' => 3840], 'image.preview' => ['width' => 480, 'height' => null], 'image.variants_async' => true, diff --git a/app/Services/UploadService.php b/app/Services/UploadService.php index 148b5292..557b6074 100644 --- a/app/Services/UploadService.php +++ b/app/Services/UploadService.php @@ -16,14 +16,29 @@ class UploadService /** Maximum total pixel count accepted (decompression-bomb guard, ~40 megapixel). */ private const MAX_IMAGE_PIXELS = 40000000; - private array $allowed = ['image/jpeg' => '.jpg','image/png' => '.png', 'image/webp' => '.webp']; + // HEIC/HEIF (#109) are accepted only when the server can actually decode + // them (ImageEngine::capabilities()['heif_read']); validateImageFile() + // gates on that so we never store an original we can't turn into web + // variants. Originals keep their .heic/.heif extension; variants are jpg/ + // webp/avif as usual. + private array $allowed = [ + 'image/jpeg' => '.jpg', + 'image/png' => '.png', + 'image/webp' => '.webp', + 'image/heic' => '.heic', + 'image/heif' => '.heif', + ]; // Magic number signatures for image validation private array $magicNumbers = [ 'image/jpeg' => ["\xFF\xD8\xFF"], 'image/png' => ["\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"], 'image/webp' => ["RIFF", "WEBP"], // RIFF...WEBP - 'image/gif' => ["GIF87a", "GIF89a"] + 'image/gif' => ["GIF87a", "GIF89a"], + // HEIC/HEIF are ISO-BMFF: the 'ftyp' box sits at byte offset 4 (not 0), + // so it's matched specially in validateImageFile(). + 'image/heic' => ["ftyp"], + 'image/heif' => ["ftyp"], ]; public function __construct(private Database $db) @@ -66,7 +81,11 @@ public static function safeUnlink(string $path): bool foreach ($allowedRoots as $root) { if ($root !== '' && str_starts_with($real, $root . DIRECTORY_SEPARATOR)) { - return @unlink($real); + // nosemgrep: this IS the confinement guard — $real is + // realpath()-resolved and verified to live under an allowed + // root before deletion; callers route here precisely so the + // unlink cannot traverse outside storage/public-media/temp. + return @unlink($real); // nosemgrep } } @@ -103,6 +122,13 @@ private function validateImageFile(string $filePath): string throw new RuntimeException('Unsupported file type: ' . ($detectedMime ?: 'unknown')); } + // 3b. HEIC/HEIF (#109) is accepted only when the server can decode it, + // so we never store an original we can't turn into web variants. + $isHeif = $detectedMime === 'image/heic' || $detectedMime === 'image/heif'; + if ($isHeif && !\App\Services\Imaging\ImageEngine::capabilities()['heif_read']) { + throw new RuntimeException('HEIC/HEIF is not supported on this server (no libheif / Imagick HEIC delegate).'); + } + // 4. Validate magic numbers (file header signatures) $fileHeader = file_get_contents($filePath, false, null, 0, 12); if ($fileHeader === false) { @@ -118,6 +144,13 @@ private function validateImageFile(string $filePath): string $isValidMagic = true; break; } + } elseif ($isHeif) { + // HEIC/HEIF are ISO-BMFF: the 'ftyp' box marker sits at byte + // offset 4, not at the start of the file. + if (substr($fileHeader, 4, 4) === 'ftyp') { + $isValidMagic = true; + break; + } } elseif (str_starts_with($fileHeader, (string) $signature)) { $isValidMagic = true; break; @@ -129,16 +162,11 @@ private function validateImageFile(string $filePath): string throw new RuntimeException('File header does not match expected format'); } - // 5. Additional validation: try to get image dimensions - $imageInfo = getimagesize($filePath); - if ($imageInfo === false) { - throw new RuntimeException('Invalid image file - cannot read dimensions'); - } - - // 6. Validate image dimensions (prevent processing of malicious files) - [$width, $height] = $imageInfo; + // 5./6. Read + validate dimensions. getimagesize() can't read HEIC, so + // readImageDimensions() falls back to Imagick for those. + [$width, $height] = $this->readImageDimensions($filePath, $detectedMime) ?? [0, 0]; if ($width <= 0 || $height <= 0 || $width > 20000 || $height > 20000) { - throw new RuntimeException('Invalid image dimensions'); + throw new RuntimeException('Invalid image file - cannot read dimensions'); } // 6b. Decompression-bomb guard: cap total pixel count before any GD/Imagick @@ -151,6 +179,36 @@ private function validateImageFile(string $filePath): string return $detectedMime; } + /** + * Read [width, height] for an image. Uses getimagesize() for standard + * formats and falls back to Imagick (header-only ping) for HEIC/HEIF, + * which getimagesize() cannot decode. Returns null when undeterminable. + * + * @return array{0:int,1:int}|null + */ + private function readImageDimensions(string $path, string $mime): ?array + { + $isHeif = $mime === 'image/heic' || $mime === 'image/heif'; + if (!$isHeif) { + $info = @getimagesize($path); + return $info === false ? null : [(int) $info[0], (int) $info[1]]; + } + if (class_exists(\Imagick::class) && !$this->imagickDisabled()) { + try { + $im = new \Imagick(); + $im->pingImage($path); // headers only — no full decode (bomb-safe) + $g = $im->getImageGeometry(); + $im->clear(); + $w = (int) ($g['width'] ?? 0); + $h = (int) ($g['height'] ?? 0); + return ($w > 0 && $h > 0) ? [$w, $h] : null; + } catch (\Throwable) { + return null; + } + } + return null; + } + public function ingestAlbumUpload(int $albumId, array $file): array { if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { @@ -199,7 +257,9 @@ public function ingestAlbumUpload(int $albumId, array $file): array throw new RuntimeException('File validation failed after upload: ' . $e->getMessage(), $e->getCode(), $e); } - [$width, $height] = getimagesize($dest) ?: [0,0]; + // HEIC-aware: getimagesize() can't read HEIC, so use the helper that + // falls back to Imagick for those originals. + [$width, $height] = $this->readImageDimensions($dest, $mime) ?? [0, 0]; // Extract EXIF and map lookups (best effort) $exifSvc = new \App\Services\ExifService($this->db); $exif = $exifSvc->extract($dest); @@ -337,6 +397,11 @@ public function ingestAlbumUpload(int $albumId, array $file): array private function resizeWithImagick(string $src, string $dest, int $targetW, string $format, int $quality): bool { + // Fast path (#109): libvips when available; falls through to Imagick + // (below) when vips is absent or can't handle this format. + if (\App\Services\Imaging\ImageEngine::encode($src, $dest, $targetW, $format, $quality)) { + return true; + } try { self::applyImagickLimits(); $im = new \Imagick($src); @@ -370,6 +435,11 @@ private function resizeWithImagick(string $src, string $dest, int $targetW, stri private function resizeWithImagickOrGd(string $src, string $dest, int $targetW, string $format, int $quality): bool { + // Fast path (#109): libvips when available (low-memory, reads HEIC). + // Returns false when vips is absent/unable → fall through to Imagick/GD. + if (\App\Services\Imaging\ImageEngine::encode($src, $dest, $targetW, $format, $quality)) { + return true; + } if (class_exists(\Imagick::class) && !$this->imagickDisabled()) { return $this->resizeWithImagick($src, $dest, $targetW, $format, $quality); } @@ -579,7 +649,10 @@ public function generateVariantsForImage(int $imageId, bool $force = false, ?str if ($ok && is_file($destPath)) { $oppositeDir = $protectedStorage->directoryForProtection(!$isProtectedAlbum); - @unlink($oppositeDir . "/{$imageId}_{$variant}.{$fmt}"); + // Route through safeUnlink so the deletion is confined to the + // allowed storage roots (path-traversal guard) instead of a + // raw unlink on a composed path. + self::safeUnlink($oppositeDir . "/{$imageId}_{$variant}.{$fmt}"); $size = (int)filesize($destPath); [$vw, $vh] = getimagesize($destPath) ?: [$targetW, 0]; $replaceKeyword = $this->db->replaceKeyword(); diff --git a/app/Tasks/ImagesGenerateCommand.php b/app/Tasks/ImagesGenerateCommand.php index 025cb6f3..94b159ce 100644 --- a/app/Tasks/ImagesGenerateCommand.php +++ b/app/Tasks/ImagesGenerateCommand.php @@ -133,10 +133,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int $variantsGenerated = 0; foreach ($breakpoints as $variant => $width) { - foreach (['avif','webp','jpg'] as $fmt) { + // 'jxl' (JPEG-XL, #109) is included only when enabled in settings + // AND the build can write it (libvips+libjxl); it is emitted by + // the libvips engine. Browser support is still nascent, so the + // frontend does not serve it yet — generation is + // opt-in via settings. + foreach (['avif','webp','jpg','jxl'] as $fmt) { if (empty($formats[$fmt])) { continue; } + if ($fmt === 'jxl' && !\App\Services\Imaging\ImageEngine::capabilities()['jxl_write']) { + continue; + } $destRelUrl = "/media/{$imageId}_{$variant}.{$fmt}"; $dest = dirname(__DIR__, 2) . '/public/media/' . "{$imageId}_{$variant}.{$fmt}"; $key = $variant . '|' . $fmt; diff --git a/app/Views/admin/settings.twig b/app/Views/admin/settings.twig index 257a37c7..9d2000f3 100644 --- a/app/Views/admin/settings.twig +++ b/app/Views/admin/settings.twig @@ -42,6 +42,13 @@ JPEG - {{ trans('admin.settings.jpg_desc') }} + +
@@ -57,6 +64,10 @@
+
+ + +
diff --git a/composer.json b/composer.json index b8324eca..fd4223e3 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,12 @@ "phpunit/phpunit": "^10.5", "phpstan/phpstan": "^2.0" }, + "suggest": { + "ext-vips": "libvips PHP extension — fast, low-memory image-variant generation (#109); install jcupitt/vips alongside it", + "jcupitt/vips": "libvips bindings used by App\\Services\\Imaging\\ImageEngine for the fast variant path (HEIC read, AVIF/JPEG-XL write when the libvips build supports them)", + "ext-imagick": "Imagick is the fallback image engine and provides HEIC read + AVIF write when libvips is unavailable", + "spatie/image-optimizer": "optional CLI optimizer chain (jpegoptim/pngquant/cwebp/avifenc) for smaller variants" + }, "autoload": { "psr-4": { "App\\": "app/" From 6b59c17e5f9c5e76ebe8154d5df86d4d33c1ee66 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 17 Jun 2026 16:18:31 +0200 Subject: [PATCH 03/11] fix: address image-pipeline review findings (#109) Applies 10 confirmed findings from the code review of #117. HEIC handling (cross-cutting group, app/Services): - Centralize HEIC dimension reads via new ImageEngine::dimensions() (libvips header/sequential, bomb-safe); readImageDimensions() now tries vips before Imagick so vips-only hosts can measure admitted HEIC instead of rejecting it (F007/F008). - Post-orientation re-read uses the HEIC-aware readImageDimensions() instead of bare getimagesize(), fixing swapped/stale dimensions for rotated HEIC originals (F006). - HEIC uploads get an immediate sm preview via the HEIC-capable variant path when the GD-only generateJpegPreview() returns null (F009). Encoder correctness (app/Services/UploadService.php, app/Tasks): - Remove the redundant second ImageEngine::encode() on the Imagick fallback by extracting resizeWithImagickOnly() (F003). - Thread STRIP_EXIF into the libvips encode() fast path so it honours STRIP_EXIF=false like the Imagick path (F012). JPEG-XL data layer (database, app/Tasks): - Allow 'jxl' in image_variants.format on both engines: widen the SQLite CHECK and MySQL ENUM, add migrate_1.4.13_{sqlite,mysql}.sql (SQLite via guarded table rebuild), regenerate template.sqlite, bump version to 1.4.13. Wrap the variant INSERT in try/catch so one bad row no longer aborts the whole images:generate run (F030). Admin UI (app/Views, storage/translations, app/Controllers): - i18n the JPEG-XL settings strings via trans() keys (F020). - Fix the quality-inputs grid to grid-cols-4 so the 4th input aligns (F022). - Diagnostics no longer reports "No capable image engine" when GD is present (F025). --- .../Admin/DiagnosticsController.php | 4 +- app/Services/Imaging/ImageEngine.php | 25 +++++++++ app/Services/UploadService.php | 48 +++++++++++++++--- app/Tasks/ImagesGenerateCommand.php | 41 +++++++++++---- app/Views/admin/settings.twig | 6 +-- database/migrations/migrate_1.4.13_mysql.sql | 7 +++ database/migrations/migrate_1.4.13_sqlite.sql | 30 +++++++++++ database/schema.mysql.sql | 2 +- database/schema.sqlite.sql | 2 +- database/template.sqlite | Bin 909312 -> 913408 bytes storage/translations/en_admin.json | 3 ++ storage/translations/it_admin.json | 3 ++ version.json | 2 +- 13 files changed, 147 insertions(+), 26 deletions(-) create mode 100644 database/migrations/migrate_1.4.13_mysql.sql create mode 100644 database/migrations/migrate_1.4.13_sqlite.sql diff --git a/app/Controllers/Admin/DiagnosticsController.php b/app/Controllers/Admin/DiagnosticsController.php index 123eb39c..c7c6288a 100644 --- a/app/Controllers/Admin/DiagnosticsController.php +++ b/app/Controllers/Admin/DiagnosticsController.php @@ -230,7 +230,9 @@ private function runDiagnostics(): array 'value' => $engine, 'message' => $caps['vips'] ? 'libvips active (fast, low-memory variant generation)' - : ($caps['imagick'] ? 'Imagick active (install php-vips for faster, lower-memory generation)' : 'No capable image engine'), + : ($caps['imagick'] + ? 'Imagick active (install php-vips for faster, lower-memory generation)' + : ($caps['gd'] ? 'GD active — basic image processing only; install php-vips or Imagick for full variant generation' : 'No capable image engine')), 'details' => [ 'Engine' => $engine, 'libvips' => $caps['vips'] ? 'Yes' : 'No', diff --git a/app/Services/Imaging/ImageEngine.php b/app/Services/Imaging/ImageEngine.php index 4a64e2ea..1655fc33 100644 --- a/app/Services/Imaging/ImageEngine.php +++ b/app/Services/Imaging/ImageEngine.php @@ -122,6 +122,31 @@ public static function encode( } } + /** + * Read [width, height] for a source image via libvips, header/sequential + * only (no full decode → preserves decompression-bomb safety). Returns null + * when vips is unavailable or cannot read the input, so callers fall back to + * their existing Imagick ping path. + * + * @return array{0:int,1:int}|null + */ + public static function dimensions(string $src): ?array + { + if (!self::vipsAvailable()) { + return null; + } + try { + // 'sequential' access reads only what's needed off the header/stream + // rather than buffering the whole raster — no full decode. + $img = \Jcupitt\Vips\Image::newFromFile($src, ['access' => 'sequential']); + $w = (int) $img->width; + $h = (int) $img->height; + return ($w > 0 && $h > 0) ? [$w, $h] : null; + } catch (\Throwable) { + return null; + } + } + /** * Best-effort post-encode optimization via an installed CLI optimizer. * Silent no-op when no suitable binary is present. diff --git a/app/Services/UploadService.php b/app/Services/UploadService.php index 557b6074..cf1645fe 100644 --- a/app/Services/UploadService.php +++ b/app/Services/UploadService.php @@ -193,6 +193,13 @@ private function readImageDimensions(string $path, string $mime): ?array $info = @getimagesize($path); return $info === false ? null : [(int) $info[0], (int) $info[1]]; } + // HEIC/HEIF: prefer libvips (header/sequential, bomb-safe) so vips-only + // hosts can measure admitted HEIC. Aligns with the heif_read gate + // (vips-OR-Imagick). Falls back to Imagick pingImage, then null. + $vipsDims = \App\Services\Imaging\ImageEngine::dimensions($path); + if ($vipsDims !== null) { + return $vipsDims; + } if (class_exists(\Imagick::class) && !$this->imagickDisabled()) { try { $im = new \Imagick(); @@ -268,11 +275,12 @@ public function ingestAlbumUpload(int $albumId, array $file): array // Normalize image orientation if needed if (isset($exif['Orientation']) && $exif['Orientation'] > 1) { $exifSvc->normalizeOrientation($dest, (int)$exif['Orientation']); - // Re-read dimensions after rotation - $size = getimagesize($dest); - if ($size) { - $width = $size[0]; - $height = $size[1]; + // Re-read dimensions after rotation via the HEIC-aware reader + // (bare getimagesize() can't measure HEIC originals). Keep prior + // values if the re-read fails — never zero out the dimensions. + $redims = $this->readImageDimensions($dest, $mime); + if ($redims !== null) { + [$width, $height] = $redims; } } @@ -362,6 +370,17 @@ public function ingestAlbumUpload(int $albumId, array $file): array $previewW = (int)($previewSettings['width'] ?? 480); $previewPath = $mediaDir . '/' . $imageId . '_sm.jpg'; $preview = ImagesService::generateJpegPreview($dest, $previewPath, $previewW); + // generateJpegPreview() routes through GD/getimagesize() which cannot + // read HEIC/HEIF, so it returns null for iPhone originals. Fall back to + // the HEIC-capable variant path (ImageEngine::encode via vips/Imagick), + // which writes the SAME jpg sm preview. Protected/NSFW media stays in + // $mediaDir, so the path resolution is unchanged. + $isHeifOriginal = $mime === 'image/heic' || $mime === 'image/heif'; + if (!$preview && $isHeifOriginal + && $this->resizeWithImagickOrGd($dest, $previewPath, $previewW, 'jpg', 82) + && is_file($previewPath)) { + $preview = $previewPath; + } if ($preview) { // The URL stays stable; MediaController maps it to public/ or // storage/protected-media after checking album access. @@ -399,9 +418,20 @@ private function resizeWithImagick(string $src, string $dest, int $targetW, stri { // Fast path (#109): libvips when available; falls through to Imagick // (below) when vips is absent or can't handle this format. - if (\App\Services\Imaging\ImageEngine::encode($src, $dest, $targetW, $format, $quality)) { + if (\App\Services\Imaging\ImageEngine::encode($src, $dest, $targetW, $format, $quality, $this->envFlag('STRIP_EXIF', true))) { return true; } + return $this->resizeWithImagickOnly($src, $dest, $targetW, $format, $quality); + } + + /** + * Imagick-only resize (no libvips fast-path prefix). Extracted from + * resizeWithImagick() so resizeWithImagickOrGd()'s fallback can reach the + * Imagick body without re-running ImageEngine::encode() a second time + * (which already failed once at the top of resizeWithImagickOrGd). + */ + private function resizeWithImagickOnly(string $src, string $dest, int $targetW, string $format, int $quality): bool + { try { self::applyImagickLimits(); $im = new \Imagick($src); @@ -437,11 +467,13 @@ private function resizeWithImagickOrGd(string $src, string $dest, int $targetW, { // Fast path (#109): libvips when available (low-memory, reads HEIC). // Returns false when vips is absent/unable → fall through to Imagick/GD. - if (\App\Services\Imaging\ImageEngine::encode($src, $dest, $targetW, $format, $quality)) { + if (\App\Services\Imaging\ImageEngine::encode($src, $dest, $targetW, $format, $quality, $this->envFlag('STRIP_EXIF', true))) { return true; } if (class_exists(\Imagick::class) && !$this->imagickDisabled()) { - return $this->resizeWithImagick($src, $dest, $targetW, $format, $quality); + // encode() already attempted+failed above; call the Imagick-only + // body directly to avoid a redundant second encode() pass. + return $this->resizeWithImagickOnly($src, $dest, $targetW, $format, $quality); } // GD fallback JPEG only $info = @getimagesize($src); diff --git a/app/Tasks/ImagesGenerateCommand.php b/app/Tasks/ImagesGenerateCommand.php index 94b159ce..b97d3fb0 100644 --- a/app/Tasks/ImagesGenerateCommand.php +++ b/app/Tasks/ImagesGenerateCommand.php @@ -6,6 +6,7 @@ use App\Services\SettingsService; use App\Support\Database; +use App\Support\Logger; use App\Traits\RegistersImageVariants; use Imagick; use RuntimeException; @@ -180,12 +181,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int // the request; we then fall back to the existing Imagick/GD // path below (zero behaviour change on hosts without vips). $engineFmt = $fmt === 'jpg' ? 'jpeg' : $fmt; + // Honour STRIP_EXIF (default true) so vips doesn't always + // strip when the operator opted to retain metadata. + $stripExif = filter_var(getenv('STRIP_EXIF') ?: 'true', FILTER_VALIDATE_BOOL); $ok = \App\Services\Imaging\ImageEngine::encode( $src, $dest, (int)$width, $engineFmt, - (int)($quality[$fmt] ?? 82) + (int)($quality[$fmt] ?? 82), + $stripExif ); if (!$ok) { @@ -203,16 +208,30 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if ($ok) { - $size = (int)filesize($dest); - [$w, $h] = getimagesize($dest) ?: [(int)$width, 0]; - $replaceKeyword = $this->db->replaceKeyword(); - $stmt = $pdo->prepare(sprintf( - '%s INTO image_variants(image_id, variant, format, path, width, height, size_bytes) VALUES(?,?,?,?,?,?,?)', - $replaceKeyword - )); - $stmt->execute([$imageId, $variant, $fmt, $destRelUrl, $w, $h, $size]); - $variantsGenerated++; - $totalGenerated++; + // Defence-in-depth: a single constraint failure (e.g. a + // format the DB schema doesn't yet allow) must not abort + // the whole run — log and continue with the next variant. + try { + $size = (int)filesize($dest); + [$w, $h] = getimagesize($dest) ?: [(int)$width, 0]; + $replaceKeyword = $this->db->replaceKeyword(); + $stmt = $pdo->prepare(sprintf( + '%s INTO image_variants(image_id, variant, format, path, width, height, size_bytes) VALUES(?,?,?,?,?,?,?)', + $replaceKeyword + )); + $stmt->execute([$imageId, $variant, $fmt, $destRelUrl, $w, $h, $size]); + $variantsGenerated++; + $totalGenerated++; + } catch (\Throwable $e) { + Logger::warning('ImagesGenerateCommand: failed to record image variant', [ + 'image_id' => $imageId, + 'variant' => $variant, + 'format' => $fmt, + 'error' => $e->getMessage(), + ], 'imaging'); + $output->writeln("Failed to record {$fmt} variant {$variant} for image #{$imageId}: {$e->getMessage()}"); + $totalErrors++; + } } else { $output->writeln("Failed to generate {$fmt} variant {$variant} for image #{$imageId}"); $totalErrors++; diff --git a/app/Views/admin/settings.twig b/app/Views/admin/settings.twig index 9d2000f3..3db086ea 100644 --- a/app/Views/admin/settings.twig +++ b/app/Views/admin/settings.twig @@ -46,12 +46,12 @@ -
+
@@ -65,7 +65,7 @@
- +
diff --git a/database/migrations/migrate_1.4.13_mysql.sql b/database/migrations/migrate_1.4.13_mysql.sql new file mode 100644 index 00000000..3ca17690 --- /dev/null +++ b/database/migrations/migrate_1.4.13_mysql.sql @@ -0,0 +1,7 @@ +-- Migration 1.4.13 (MySQL) — allow 'jxl' in image_variants.format (#109) +-- +-- JPEG-XL variants are emitted by the libvips engine when the build supports +-- it. The ENUM previously forbade 'jxl', so the INSERT/REPLACE silently failed. +-- Single ALTER, no inner semicolons (migration runner splits on ';'). + +ALTER TABLE image_variants MODIFY COLUMN format ENUM('avif','webp','jpg','jxl') NOT NULL; diff --git a/database/migrations/migrate_1.4.13_sqlite.sql b/database/migrations/migrate_1.4.13_sqlite.sql new file mode 100644 index 00000000..e46823fb --- /dev/null +++ b/database/migrations/migrate_1.4.13_sqlite.sql @@ -0,0 +1,30 @@ +-- Migration 1.4.13 (SQLite) — allow 'jxl' in image_variants.format (#109) +-- +-- SQLite cannot ALTER a CHECK constraint in place, so we rebuild the table with +-- the widened CHECK and copy the rows across. Each statement has NO inner +-- semicolons so it passes cleanly through the migration runner's splitter. +-- Column list + constraints mirror schema.sqlite.sql exactly (the table has no +-- secondary indexes beyond the inline PK / UNIQUE). + +PRAGMA foreign_keys=OFF; + +CREATE TABLE image_variants_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + image_id INTEGER NOT NULL, + variant TEXT NOT NULL, + format TEXT NOT NULL CHECK(format IN ('avif', 'webp', 'jpg', 'jxl')), + path TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + size_bytes INTEGER NOT NULL, + UNIQUE(image_id, variant, format), + FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE +); + +INSERT INTO image_variants_new (id, image_id, variant, format, path, width, height, size_bytes) SELECT id, image_id, variant, format, path, width, height, size_bytes FROM image_variants; + +DROP TABLE image_variants; + +ALTER TABLE image_variants_new RENAME TO image_variants; + +PRAGMA foreign_keys=ON; diff --git a/database/schema.mysql.sql b/database/schema.mysql.sql index 2c5d3f72..4e279a4d 100644 --- a/database/schema.mysql.sql +++ b/database/schema.mysql.sql @@ -322,7 +322,7 @@ CREATE TABLE IF NOT EXISTS `image_variants` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `image_id` INT UNSIGNED NOT NULL, `variant` VARCHAR(50) NOT NULL, - `format` ENUM('avif', 'webp', 'jpg') NOT NULL, + `format` ENUM('avif', 'webp', 'jpg', 'jxl') NOT NULL, `path` VARCHAR(255) NOT NULL, `width` INT NOT NULL, `height` INT NOT NULL, diff --git a/database/schema.sqlite.sql b/database/schema.sqlite.sql index 9c06c07a..2ab13471 100644 --- a/database/schema.sqlite.sql +++ b/database/schema.sqlite.sql @@ -350,7 +350,7 @@ CREATE TABLE IF NOT EXISTS image_variants ( id INTEGER PRIMARY KEY AUTOINCREMENT, image_id INTEGER NOT NULL, variant TEXT NOT NULL, - format TEXT NOT NULL CHECK(format IN ('avif', 'webp', 'jpg')), + format TEXT NOT NULL CHECK(format IN ('avif', 'webp', 'jpg', 'jxl')), path TEXT NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL, diff --git a/database/template.sqlite b/database/template.sqlite index 50631b0d243a3d2e7322933005d01bd5bfbd09d7..888702bd3e17b92ee8c9bf0084ae5a8261d30d1e 100644 GIT binary patch delta 10770 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 RT6@(x;&jLV!8)h>_+J`Ju)_cV delta 12842 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 J{HA;}`wwcs5MTfR diff --git a/storage/translations/en_admin.json b/storage/translations/en_admin.json index 055bdb57..280a2b61 100644 --- a/storage/translations/en_admin.json +++ b/storage/translations/en_admin.json @@ -1934,6 +1934,9 @@ "admin.settings.jpg_desc": "Universal compatibility, fallback format", "admin.settings.jpg_quality": "JPEG Quality", "admin.settings.justified": "Justified", + "admin.settings.jxl": "JPEG-XL", + "admin.settings.jxl_desc": "next-gen format; generated only when the server's libvips is built with libjxl. Browser support is still limited, so it is opt-in.", + "admin.settings.jxl_quality": "JPEG-XL Quality", "admin.settings.language_en": "English", "admin.settings.language_it": "Italiano", "admin.settings.lazy_loading": "Lazy Loading", diff --git a/storage/translations/it_admin.json b/storage/translations/it_admin.json index d6f73746..6ca00597 100644 --- a/storage/translations/it_admin.json +++ b/storage/translations/it_admin.json @@ -1948,6 +1948,9 @@ "admin.settings.jpg_desc": "Compatibilità universale, formato di fallback", "admin.settings.jpg_quality": "Qualità JPEG", "admin.settings.justified": "Giustificato", + "admin.settings.jxl": "JPEG-XL", + "admin.settings.jxl_desc": "formato di nuova generazione; generato solo quando la libvips del server è compilata con libjxl. Il supporto dei browser è ancora limitato, quindi è opzionale.", + "admin.settings.jxl_quality": "Qualità JPEG-XL", "admin.settings.language_en": "English", "admin.settings.language_it": "Italiano", "admin.settings.lazy_loading": "Caricamento Lazy", diff --git a/version.json b/version.json index ad1dc430..6f98fb02 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "name": "Cimaise", - "version": "1.4.12", + "version": "1.4.13", "description": "Professional Photography Portfolio CMS - A modern, minimalist content management system designed for photographers to showcase their work with elegant galleries, advanced image processing, and comprehensive SEO optimization.", "author": "Fabio", "license": "MIT", From c9280cbe47074dcd836366939f3b898817c46cf2 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 17 Jun 2026 17:05:56 +0200 Subject: [PATCH 04/11] fix: serve JPEG-XL + ImageEngine cleanup (walkthrough findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies 4 findings promoted via /adamsreview:walkthrough. - F013: serve JPEG-XL to capable browsers. buildResponsiveSources() gains a 'jxl' bucket (avif > jxl > webp > jpg), and every template emits a after avif when jxl variants exist (guarded exactly like avif, so jxl-disabled — the default — renders no empty source). Generation unchanged; LCP/Early Hints single-format preload stays on avif. - F014: drop dead optimization paths. Remove the unreachable pngquant branch from ImageEngine::optimize() and stop detecting/surfacing opt_pngquant/opt_cwebp/opt_avifenc (never invoked); keep jpegoptim. - F028: ImageEngine::optimize() is now private static (only the internal encode() pipeline calls it). - F015: correct the encode() docstring — $format (not the $dest extension) selects the encoder. --- .../Admin/DiagnosticsController.php | 3 --- app/Controllers/Frontend/PageController.php | 8 +++---- app/Services/CacheWarmService.php | 4 ++-- app/Services/ImageVariantsService.php | 4 ++-- app/Services/Imaging/ImageEngine.php | 19 ++++++++------- app/Views/frontend/_album_card.twig | 6 +++++ app/Views/frontend/_album_gallery.twig | 12 ++++++++++ app/Views/frontend/_gallery_content.twig | 9 +++++++ .../frontend/_gallery_creative_layout.twig | 13 +++++++++- app/Views/frontend/_gallery_item.twig | 6 +++++ .../frontend/_gallery_magazine_content.twig | 3 +++ .../frontend/_gallery_masonry_portfolio.twig | 6 +++++ app/Views/frontend/_gallery_wall_scroll.twig | 6 +++++ app/Views/frontend/_image_item.twig | 6 +++++ app/Views/frontend/_image_item_masonry.twig | 6 +++++ app/Views/frontend/_lqip_macros.twig | 3 +++ app/Views/frontend/_picture.twig | 6 +++++ app/Views/frontend/album.twig | 18 ++++++++++++++ app/Views/frontend/gallery.twig | 18 ++++++++++++++ app/Views/frontend/gallery_magazine.twig | 3 +++ .../frontend/home/_infinite_gallery.twig | 3 +++ app/Views/frontend/home_gallery.twig | 24 +++++++++++++++++++ app/Views/frontend/home_modern.twig | 14 +++++++++++ app/Views/frontend/home_parallax.twig | 6 +++++ 24 files changed, 185 insertions(+), 21 deletions(-) diff --git a/app/Controllers/Admin/DiagnosticsController.php b/app/Controllers/Admin/DiagnosticsController.php index c7c6288a..90a9163e 100644 --- a/app/Controllers/Admin/DiagnosticsController.php +++ b/app/Controllers/Admin/DiagnosticsController.php @@ -243,9 +243,6 @@ private function runDiagnostics(): array 'JPEG-XL write' => $caps['jxl_write'] ? 'Yes' : 'No', 'Optimizers' => implode(', ', array_keys(array_filter([ 'jpegoptim' => $caps['opt_jpegoptim'], - 'pngquant' => $caps['opt_pngquant'], - 'cwebp' => $caps['opt_cwebp'], - 'avifenc' => $caps['opt_avifenc'], ]))) ?: 'none', ], ]; diff --git a/app/Controllers/Frontend/PageController.php b/app/Controllers/Frontend/PageController.php index 5b7f3984..8f0af132 100644 --- a/app/Controllers/Frontend/PageController.php +++ b/app/Controllers/Frontend/PageController.php @@ -1237,7 +1237,7 @@ public function album(Request $request, Response $response, array $args): Respon } // Build responsive sources for - $sources = ['avif' => [], 'webp' => [], 'jpg' => []]; + $sources = ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []]; foreach (($image['variants'] ?? []) as $v) { if (!isset($sources[$v['format']])) { continue; @@ -1829,7 +1829,7 @@ public function albumTemplate(Request $request, Response $response, array $args) // Never use original_path (points to /storage/originals/ which is not web-accessible) $bestUrl = ''; $lightboxUrl = ''; - $sources = ['avif' => [], 'webp' => [], 'jpg' => []]; + $sources = ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []]; try { $variants = $variantsByImage[(int) $img['id']] ?? []; @@ -2785,7 +2785,7 @@ private function processImageSourcesBatch(array $images, bool $isProtectedAlbum } catch (\Throwable $e) { Logger::warning('PageController: Error processing image sources (batch)', ['error' => $e->getMessage()], 'frontend'); foreach ($images as &$image) { - $image['sources'] = ['avif' => [], 'webp' => [], 'jpg' => []]; + $image['sources'] = ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []]; $image['variants'] = []; // Security: never expose non-public storage paths $fallback = $image['original_path'] ?? ''; @@ -2803,7 +2803,7 @@ private function processImageSourcesBatch(array $images, bool $isProtectedAlbum // This eliminates hundreds of is_file() calls per page load $imageIndex = 0; foreach ($images as &$image) { - $sources = ['avif' => [], 'webp' => [], 'jpg' => []]; + $sources = ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []]; $variants = $variantsByImage[(int) $image['id']] ?? []; foreach ($variants as $variant) { diff --git a/app/Services/CacheWarmService.php b/app/Services/CacheWarmService.php index 5ac6dc75..36d09117 100644 --- a/app/Services/CacheWarmService.php +++ b/app/Services/CacheWarmService.php @@ -865,7 +865,7 @@ private function processImageSourcesBatch(array $images): array } catch (\Throwable $e) { Logger::warning('CacheWarmService: Error processing image sources (batch)', ['error' => $e->getMessage()], 'frontend'); foreach ($images as &$image) { - $image['sources'] = ['avif' => [], 'webp' => [], 'jpg' => []]; + $image['sources'] = ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []]; $image['variants'] = []; // Security: never expose non-public storage paths $fallback = $image['original_path'] ?? ''; @@ -881,7 +881,7 @@ private function processImageSourcesBatch(array $images): array // PERFORMANCE: Trust database records instead of checking filesystem for every variant $imageIndex = 0; foreach ($images as &$image) { - $sources = ['avif' => [], 'webp' => [], 'jpg' => []]; + $sources = ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []]; $variants = $variantsByImage[(int) $image['id']] ?? []; foreach ($variants as $variant) { diff --git a/app/Services/ImageVariantsService.php b/app/Services/ImageVariantsService.php index 31c0c2d0..5518d3c2 100644 --- a/app/Services/ImageVariantsService.php +++ b/app/Services/ImageVariantsService.php @@ -164,11 +164,11 @@ public static function getBestLightboxVariant(array $variants): ?array * Build responsive sources for element * * @param array $variants All variants for an image - * @return array Sources grouped by format ['avif' => [], 'webp' => [], 'jpg' => []] + * @return array Sources grouped by format ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []] */ public static function buildResponsiveSources(array $variants): array { - $sources = ['avif' => [], 'webp' => [], 'jpg' => []]; + $sources = ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []]; foreach ($variants as $variant) { // Skip storage paths diff --git a/app/Services/Imaging/ImageEngine.php b/app/Services/Imaging/ImageEngine.php index 1655fc33..f1eccd61 100644 --- a/app/Services/Imaging/ImageEngine.php +++ b/app/Services/Imaging/ImageEngine.php @@ -52,11 +52,10 @@ public static function capabilities(): array 'heif_read' => ($vips && self::vipsCanLoad('probe.heic')) || self::imagickSupports('HEIC'), 'avif_write' => ($vips && self::vipsCanWrite('.avif')) || self::imagickSupports('AVIF'), 'jxl_write' => $vips && self::vipsCanWrite('.jxl'), - // Post-encode optimizers (used opportunistically). + // Post-encode optimizer (only jpegoptim is actually invoked, for + // jpeg variants; webp/avif/jxl are emitted at target quality and + // PNG is never produced, so no other optimizer is detected). 'opt_jpegoptim' => self::binaryExists('jpegoptim'), - 'opt_pngquant' => self::binaryExists('pngquant'), - 'opt_cwebp' => self::binaryExists('cwebp'), - 'opt_avifenc' => self::binaryExists('avifenc'), ]; return self::$caps; @@ -68,9 +67,12 @@ public static function capabilities(): array * its existing Imagick/GD path). * * @param string $src Source path (any vips-readable format, incl. HEIC). - * @param string $dest Destination path; extension decides the format. + * @param string $dest Destination path; only where the file is written + * (it does not influence the output format). * @param int $targetW Target width in px (height auto, aspect kept). - * @param string $format 'jpeg' | 'webp' | 'avif' | 'jxl'. + * @param string $format 'jpeg' | 'webp' | 'avif' | 'jxl' — selects the + * encoder (passed to writeOptions); this argument, + * not the $dest extension, decides the output format. * @param int $quality 1-100. * @param bool $strip Strip metadata from the variant (privacy). */ @@ -151,7 +153,7 @@ public static function dimensions(string $src): ?array * Best-effort post-encode optimization via an installed CLI optimizer. * Silent no-op when no suitable binary is present. */ - public static function optimize(string $path, string $format): void + private static function optimize(string $path, string $format): void { if (!is_file($path)) { return; @@ -160,11 +162,10 @@ public static function optimize(string $path, string $format): void if (($format === 'jpeg' || $format === 'jpg') && $caps['opt_jpegoptim']) { self::run(['jpegoptim', '--strip-all', '--quiet', $path]); - } elseif ($format === 'png' && $caps['opt_pngquant']) { - self::run(['pngquant', '--force', '--skip-if-larger', '--output', $path, $path]); } // webp/avif are emitted at target quality by the encoder; re-optimizing // risks double-compression artefacts, so they are intentionally skipped. + // ImageEngine never encodes PNG, so no PNG optimizer is wired in. } // ── internals ──────────────────────────────────────────────────────── diff --git a/app/Views/frontend/_album_card.twig b/app/Views/frontend/_album_card.twig index 0cdb6ac7..c5631bf6 100644 --- a/app/Views/frontend/_album_card.twig +++ b/app/Views/frontend/_album_card.twig @@ -74,6 +74,7 @@ {% if has_variants %} {% set variants_avif = [] %} + {% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {% for variant in album.cover.variants %} @@ -81,6 +82,8 @@ {% set variant_url = variant.path starts with '/' ? base_path ~ variant.path : variant.path %} {% if variant.format == 'avif' %} {% set variants_avif = variants_avif|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} + {% elseif variant.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'webp' %} {% set variants_webp = variants_webp|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'jpg' %} @@ -98,6 +101,9 @@ {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} diff --git a/app/Views/frontend/_album_gallery.twig b/app/Views/frontend/_album_gallery.twig index 31e228f9..9f300ea4 100644 --- a/app/Views/frontend/_album_gallery.twig +++ b/app/Views/frontend/_album_gallery.twig @@ -95,6 +95,9 @@ {% if image.sources.avif|length %} {% endif %} + {% if image.sources.jxl|length %} + + {% endif %} {% if image.sources.webp|length %} {% endif %} @@ -178,6 +181,9 @@ {% if image.sources.avif|length %} {% endif %} + {% if image.sources.jxl|length %} + + {% endif %} {% if image.sources.webp|length %} {% endif %} @@ -238,6 +244,7 @@ {% if image.custom_fields is defined and image.custom_fields|length > 0 %}data-custom-fields="{{ image.custom_fields|json_encode|e('html_attr') }}"{% endif %}> {% set variants_avif = [] %} + {% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {# Security: never use original_path (points to /storage/originals/ which is not web-accessible) #} @@ -250,6 +257,8 @@ {% set variant_url = variant.path starts with '/' ? base_path ~ variant.path : variant.path %} {% if variant.format == 'avif' %} {% set variants_avif = variants_avif|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} + {% elseif variant.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'webp' %} {% set variants_webp = variants_webp|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'jpg' %} @@ -263,6 +272,9 @@ {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} diff --git a/app/Views/frontend/_gallery_content.twig b/app/Views/frontend/_gallery_content.twig index dcb85fe3..cbacb4eb 100644 --- a/app/Views/frontend/_gallery_content.twig +++ b/app/Views/frontend/_gallery_content.twig @@ -71,6 +71,9 @@ {% if image.sources.avif|length %} {% endif %} + {% if image.sources.jxl|length %} + + {% endif %} {% if image.sources.webp|length %} {% endif %} @@ -150,6 +153,9 @@ {% if image.sources.avif|length %} {% endif %} + {% if image.sources.jxl|length %} + + {% endif %} {% if image.sources.webp|length %} {% endif %} @@ -230,6 +236,9 @@ {% if image.sources.avif|length %} {% endif %} + {% if image.sources.jxl|length %} + + {% endif %} {% if image.sources.webp|length %} {% endif %} diff --git a/app/Views/frontend/_gallery_creative_layout.twig b/app/Views/frontend/_gallery_creative_layout.twig index 20b8ebf1..23192b6b 100644 --- a/app/Views/frontend/_gallery_creative_layout.twig +++ b/app/Views/frontend/_gallery_creative_layout.twig @@ -209,6 +209,7 @@ {% set variants_avif = [] %} + {% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {% set best_jpg_path = image.fallback_src|default(image.url|default('')) %} @@ -217,12 +218,17 @@ {% endif %} {% set best_jpg_w = 0 %} - {% if image.sources is defined and (image.sources.avif|length or image.sources.webp|length or image.sources.jpg|length) %} + {% if image.sources is defined and (image.sources.avif|length or image.sources.jxl|length or image.sources.webp|length or image.sources.jpg|length) %} {% for src in image.sources.avif %} {% set src_url = src|split(' ')|first %} {% set src_rest = src|split(' ')|slice(1)|join(' ')|trim %} {% set variants_avif = variants_avif|merge([(src_url starts with '/' ? base_path ~ src_url : src_url) ~ (src_rest ? ' ' ~ src_rest : '')]) %} {% endfor %} + {% for src in image.sources.jxl %} + {% set src_url = src|split(' ')|first %} + {% set src_rest = src|split(' ')|slice(1)|join(' ')|trim %} + {% set variants_jxl = variants_jxl|merge([(src_url starts with '/' ? base_path ~ src_url : src_url) ~ (src_rest ? ' ' ~ src_rest : '')]) %} + {% endfor %} {% for src in image.sources.webp %} {% set src_url = src|split(' ')|first %} {% set src_rest = src|split(' ')|slice(1)|join(' ')|trim %} @@ -238,6 +244,8 @@ {% set variant_url = variant.path starts with '/' ? base_path ~ variant.path : variant.path %} {% if variant.format == 'avif' %} {% set variants_avif = variants_avif|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} + {% elseif variant.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'webp' %} {% set variants_webp = variants_webp|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'jpg' %} @@ -253,6 +261,9 @@ {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} diff --git a/app/Views/frontend/_gallery_item.twig b/app/Views/frontend/_gallery_item.twig index a3b180c7..97a785fd 100644 --- a/app/Views/frontend/_gallery_item.twig +++ b/app/Views/frontend/_gallery_item.twig @@ -65,6 +65,7 @@
{% set variants_avif = [] %} + {% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {# Security: never use original_path (points to /storage/originals/ which is not web-accessible) #} @@ -77,6 +78,8 @@ {% set variant_url = variant.path starts with '/' ? base_path ~ variant.path : variant.path %} {% if variant.format == 'avif' %} {% set variants_avif = variants_avif|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} + {% elseif variant.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'webp' %} {% set variants_webp = variants_webp|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'jpg' %} @@ -93,6 +96,9 @@ {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} diff --git a/app/Views/frontend/_gallery_magazine_content.twig b/app/Views/frontend/_gallery_magazine_content.twig index dfffd639..4a1a6379 100644 --- a/app/Views/frontend/_gallery_magazine_content.twig +++ b/app/Views/frontend/_gallery_magazine_content.twig @@ -6,6 +6,9 @@ {% if image.sources is defined and image.sources.avif is defined and image.sources.avif|length %} {% endif %} + {% if image.sources is defined and image.sources.jxl is defined and image.sources.jxl|length %} + + {% endif %} {% if image.sources is defined and image.sources.webp is defined and image.sources.webp|length %} {% endif %} diff --git a/app/Views/frontend/_gallery_masonry_portfolio.twig b/app/Views/frontend/_gallery_masonry_portfolio.twig index 74f53241..5d3e46a2 100644 --- a/app/Views/frontend/_gallery_masonry_portfolio.twig +++ b/app/Views/frontend/_gallery_masonry_portfolio.twig @@ -123,6 +123,7 @@ {% set variants_avif = [] %} + {% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {% set best_jpg_path = image.fallback_src|default(image.url|default('')) %} @@ -134,6 +135,8 @@ {% set variant_url = variant.path starts with '/' ? base_path ~ variant.path : variant.path %} {% if variant.format == 'avif' %} {% set variants_avif = variants_avif|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} + {% elseif variant.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'webp' %} {% set variants_webp = variants_webp|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'jpg' %} @@ -148,6 +151,9 @@ {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} diff --git a/app/Views/frontend/_gallery_wall_scroll.twig b/app/Views/frontend/_gallery_wall_scroll.twig index ec6fb440..3cb6fa20 100644 --- a/app/Views/frontend/_gallery_wall_scroll.twig +++ b/app/Views/frontend/_gallery_wall_scroll.twig @@ -209,6 +209,9 @@ {% if image.sources.avif|length %} {% endif %} + {% if image.sources.jxl|length %} + + {% endif %} {% if image.sources.webp|length %} {% endif %} @@ -280,6 +283,9 @@ {% if image.sources.avif|length %} {% endif %} + {% if image.sources.jxl|length %} + + {% endif %} {% if image.sources.webp|length %} {% endif %} diff --git a/app/Views/frontend/_image_item.twig b/app/Views/frontend/_image_item.twig index 8d4f62ed..e51fea88 100644 --- a/app/Views/frontend/_image_item.twig +++ b/app/Views/frontend/_image_item.twig @@ -46,6 +46,7 @@
{% set variants_avif = [] %} + {% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {# Security: never use original_path (points to /storage/originals/ which is not web-accessible) #} @@ -58,6 +59,8 @@ {% set variant_url = variant.path starts with '/' ? base_path ~ variant.path : variant.path %} {% if variant.format == 'avif' %} {% set variants_avif = variants_avif|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} + {% elseif variant.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'webp' %} {% set variants_webp = variants_webp|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'jpg' %} @@ -79,6 +82,9 @@ {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} diff --git a/app/Views/frontend/_image_item_masonry.twig b/app/Views/frontend/_image_item_masonry.twig index a5ff49de..4e1c57b8 100644 --- a/app/Views/frontend/_image_item_masonry.twig +++ b/app/Views/frontend/_image_item_masonry.twig @@ -36,6 +36,7 @@
{% set variants_avif = [] %} + {% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {# Security: never use original_path (points to /storage/originals/ which is not web-accessible) #} @@ -48,6 +49,8 @@ {% set variant_url = variant.path starts with '/' ? base_path ~ variant.path : variant.path %} {% if variant.format == 'avif' %} {% set variants_avif = variants_avif|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} + {% elseif variant.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'webp' %} {% set variants_webp = variants_webp|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'jpg' %} @@ -62,6 +65,9 @@ {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} diff --git a/app/Views/frontend/_lqip_macros.twig b/app/Views/frontend/_lqip_macros.twig index 16626147..107a30b6 100644 --- a/app/Views/frontend/_lqip_macros.twig +++ b/app/Views/frontend/_lqip_macros.twig @@ -50,6 +50,9 @@ {% if image.sources.avif|default([])|length > 0 %} {% endif %} + {% if image.sources.jxl|default([])|length > 0 %} + + {% endif %} {% if image.sources.webp|default([])|length > 0 %} {% endif %} diff --git a/app/Views/frontend/_picture.twig b/app/Views/frontend/_picture.twig index 4f8b4b84..b5a884fc 100644 --- a/app/Views/frontend/_picture.twig +++ b/app/Views/frontend/_picture.twig @@ -1,12 +1,15 @@ {# Reusable responsive partial #} {# Inputs: img (object/array with width,height,alt_text,original_path), variants (list like image_variants rows), index (optional), layout (optional), context (optional string used to derive img_context for image_priority/layout decisions) #} {% set variants_avif = [] %} +{% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {% for v in variants %} {% set v_url = v.path starts with '/' ? base_path ~ v.path : v.path %} {% if v.format == 'avif' %} {% set variants_avif = variants_avif|merge([v_url ~ ' ' ~ v.width ~ 'w']) %} + {% elseif v.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([v_url ~ ' ' ~ v.width ~ 'w']) %} {% elseif v.format == 'webp' %} {% set variants_webp = variants_webp|merge([v_url ~ ' ' ~ v.width ~ 'w']) %} {% elseif v.format == 'jpg' %} @@ -26,6 +29,9 @@ {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} diff --git a/app/Views/frontend/album.twig b/app/Views/frontend/album.twig index 357e1c71..26641da4 100644 --- a/app/Views/frontend/album.twig +++ b/app/Views/frontend/album.twig @@ -585,6 +585,7 @@ document.addEventListener('click', function(event) { {% if image.custom_fields is defined and image.custom_fields|length > 0 %}data-custom-fields="{{ image.custom_fields|json_encode|e('html_attr') }}"{% endif %}> {% set variants_avif = [] %} + {% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {% set best_jpg_path = image.fallback_src|default(image.url|default('')) %} @@ -596,6 +597,8 @@ document.addEventListener('click', function(event) { {% set variant_url = variant.path starts with '/' ? base_path ~ variant.path : variant.path %} {% if variant.format == 'avif' %} {% set variants_avif = variants_avif|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} + {% elseif variant.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'webp' %} {% set variants_webp = variants_webp|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'jpg' %} @@ -610,6 +613,9 @@ document.addEventListener('click', function(event) { {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} @@ -736,6 +742,7 @@ document.addEventListener('click', function(event) { {% if image.custom_fields is defined and image.custom_fields|length > 0 %}data-custom-fields="{{ image.custom_fields|json_encode|e('html_attr') }}"{% endif %}> {% set variants_avif = [] %} + {% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {% set best_jpg_path = image.fallback_src|default(image.url|default('')) %} @@ -747,6 +754,8 @@ document.addEventListener('click', function(event) { {% set variant_url = variant.path starts with '/' ? base_path ~ variant.path : variant.path %} {% if variant.format == 'avif' %} {% set variants_avif = variants_avif|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} + {% elseif variant.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'webp' %} {% set variants_webp = variants_webp|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'jpg' %} @@ -761,6 +770,9 @@ document.addEventListener('click', function(event) { {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} @@ -850,6 +862,7 @@ document.addEventListener('click', function(event) { {% if image.custom_fields is defined and image.custom_fields|length > 0 %}data-custom-fields="{{ image.custom_fields|json_encode|e('html_attr') }}"{% endif %}> {% set variants_avif = [] %} + {% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {% set best_jpg_path = image.fallback_src|default(image.url|default('')) %} @@ -861,6 +874,8 @@ document.addEventListener('click', function(event) { {% set variant_url = variant.path starts with '/' ? base_path ~ variant.path : variant.path %} {% if variant.format == 'avif' %} {% set variants_avif = variants_avif|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} + {% elseif variant.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'webp' %} {% set variants_webp = variants_webp|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'jpg' %} @@ -875,6 +890,9 @@ document.addEventListener('click', function(event) { {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} diff --git a/app/Views/frontend/gallery.twig b/app/Views/frontend/gallery.twig index 0daa07d8..89734661 100644 --- a/app/Views/frontend/gallery.twig +++ b/app/Views/frontend/gallery.twig @@ -336,6 +336,7 @@ {% if image.custom_fields is defined and image.custom_fields|length > 0 %}data-custom-fields="{{ image.custom_fields|json_encode|e('html_attr') }}"{% endif %}> {% set variants_avif = [] %} + {% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {% set best_jpg_path = image.fallback_src|default(image.url|default('')) %} @@ -347,6 +348,8 @@ {% set variant_url = variant.path starts with '/' ? base_path ~ variant.path : variant.path %} {% if variant.format == 'avif' %} {% set variants_avif = variants_avif|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} + {% elseif variant.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'webp' %} {% set variants_webp = variants_webp|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'jpg' %} @@ -361,6 +364,9 @@ {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} @@ -486,6 +492,7 @@ {% if image.custom_fields is defined and image.custom_fields|length > 0 %}data-custom-fields="{{ image.custom_fields|json_encode|e('html_attr') }}"{% endif %}> {% set variants_avif = [] %} + {% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {% set best_jpg_path = image.fallback_src|default(image.url|default('')) %} @@ -497,6 +504,8 @@ {% set variant_url = variant.path starts with '/' ? base_path ~ variant.path : variant.path %} {% if variant.format == 'avif' %} {% set variants_avif = variants_avif|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} + {% elseif variant.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'webp' %} {% set variants_webp = variants_webp|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'jpg' %} @@ -511,6 +520,9 @@ {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} @@ -596,6 +608,7 @@ {% if image.custom_fields is defined and image.custom_fields|length > 0 %}data-custom-fields="{{ image.custom_fields|json_encode|e('html_attr') }}"{% endif %}> {% set variants_avif = [] %} + {% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {% set best_jpg_path = image.fallback_src|default(image.url|default('')) %} @@ -607,6 +620,8 @@ {% set variant_url = variant.path starts with '/' ? base_path ~ variant.path : variant.path %} {% if variant.format == 'avif' %} {% set variants_avif = variants_avif|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} + {% elseif variant.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'webp' %} {% set variants_webp = variants_webp|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'jpg' %} @@ -621,6 +636,9 @@ {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} diff --git a/app/Views/frontend/gallery_magazine.twig b/app/Views/frontend/gallery_magazine.twig index 744ffb43..02048bf4 100644 --- a/app/Views/frontend/gallery_magazine.twig +++ b/app/Views/frontend/gallery_magazine.twig @@ -145,6 +145,9 @@ {% if image.sources is defined and image.sources.avif is defined and image.sources.avif|length %} {% endif %} + {% if image.sources is defined and image.sources.jxl is defined and image.sources.jxl|length %} + + {% endif %} {% if image.sources is defined and image.sources.webp is defined and image.sources.webp|length %} {% endif %} diff --git a/app/Views/frontend/home/_infinite_gallery.twig b/app/Views/frontend/home/_infinite_gallery.twig index 41d4bc36..e6bfa564 100644 --- a/app/Views/frontend/home/_infinite_gallery.twig +++ b/app/Views/frontend/home/_infinite_gallery.twig @@ -15,6 +15,9 @@ {% if image.sources is defined and image.sources.avif is defined and image.sources.avif|length %} {% endif %} + {% if image.sources is defined and image.sources.jxl is defined and image.sources.jxl|length %} + + {% endif %} {% if image.sources is defined and image.sources.webp is defined and image.sources.webp|length %} {% endif %} diff --git a/app/Views/frontend/home_gallery.twig b/app/Views/frontend/home_gallery.twig index 8251027e..0b2f18d8 100644 --- a/app/Views/frontend/home_gallery.twig +++ b/app/Views/frontend/home_gallery.twig @@ -239,6 +239,7 @@ html.dark .gallery-wall-item::after { {% set isHorizontal = (image.width|default(1600) / image.height|default(1000)) > 1.2 %} {# Build high-quality srcsets from sources #} {% set srcset_avif = [] %} + {% set srcset_jxl = [] %} {% set srcset_webp = [] %} {% set srcset_jpg = [] %} {% for src in image.sources.avif|default([]) %} @@ -249,6 +250,14 @@ html.dark .gallery-wall-item::after { {% set srcset_avif = srcset_avif|merge([(src_url starts with '/' ? base_path ~ src_url : src_url) ~ ' ' ~ src_rest]) %} {% endif %} {% endfor %} + {% for src in image.sources.jxl|default([]) %} + {% set src_parts = src|split(' ') %} + {% set src_url = src_parts[0]|default('') %} + {% set src_rest = src_parts[1]|default('') %} + {% if src_url != '' %} + {% set srcset_jxl = srcset_jxl|merge([(src_url starts with '/' ? base_path ~ src_url : src_url) ~ ' ' ~ src_rest]) %} + {% endif %} + {% endfor %} {% for src in image.sources.webp|default([]) %} {% set src_parts = src|split(' ') %} {% set src_url = src_parts[0]|default('') %} @@ -273,6 +282,9 @@ html.dark .gallery-wall-item::after { {% if srcset_avif %} {% endif %} + {% if srcset_jxl %} + + {% endif %} {% if srcset_webp %} {% endif %} @@ -301,6 +313,7 @@ html.dark .gallery-wall-item::after { {% set isHorizontal = (image.width|default(1600) / image.height|default(1000)) > 1.2 %} {# Build srcsets for mobile #} {% set srcset_avif = [] %} + {% set srcset_jxl = [] %} {% set srcset_webp = [] %} {% set srcset_jpg = [] %} {% for src in image.sources.avif|default([]) %} @@ -311,6 +324,14 @@ html.dark .gallery-wall-item::after { {% set srcset_avif = srcset_avif|merge([(src_url starts with '/' ? base_path ~ src_url : src_url) ~ ' ' ~ src_rest]) %} {% endif %} {% endfor %} + {% for src in image.sources.jxl|default([]) %} + {% set src_parts = src|split(' ') %} + {% set src_url = src_parts[0]|default('') %} + {% set src_rest = src_parts[1]|default('') %} + {% if src_url != '' %} + {% set srcset_jxl = srcset_jxl|merge([(src_url starts with '/' ? base_path ~ src_url : src_url) ~ ' ' ~ src_rest]) %} + {% endif %} + {% endfor %} {% for src in image.sources.webp|default([]) %} {% set src_parts = src|split(' ') %} {% set src_url = src_parts[0]|default('') %} @@ -335,6 +356,9 @@ html.dark .gallery-wall-item::after { {% if srcset_avif %} {% endif %} + {% if srcset_jxl %} + + {% endif %} {% if srcset_webp %} {% endif %} diff --git a/app/Views/frontend/home_modern.twig b/app/Views/frontend/home_modern.twig index c12a711a..f5d01ed2 100644 --- a/app/Views/frontend/home_modern.twig +++ b/app/Views/frontend/home_modern.twig @@ -424,6 +424,7 @@ {% set fallback_path = image.fallback_src ?? '' %} {% set img_src = fallback_path starts with '/' ? base_path ~ fallback_path : fallback_path %} {% set srcset_avif = [] %} + {% set srcset_jxl = [] %} {% set srcset_webp = [] %} {% set srcset_jpg = [] %} {% for src in image.sources.avif|default([]) %} @@ -437,6 +438,16 @@ {% set srcset_avif = srcset_avif|merge([(src_url starts with '/' ? base_path ~ src_url : src_url) ~ ' ' ~ src_rest]) %} {% endif %} {% endfor %} + {% for src in image.sources.jxl|default([]) %} + {% set src_str = src is iterable ? (src.url|default('') ~ (src.width ? (' ' ~ src.width ~ 'w') : '')) : src %} + {% set src_parts = src_str|split(' ') %} + {% set src_url = src_parts|first|default('') %} + {% set src_rest = src_parts|slice(1)|join(' ')|trim %} + {% set src_rest = src_rest == '' ? '1x' : src_rest %} + {% if src_url != '' %} + {% set srcset_jxl = srcset_jxl|merge([(src_url starts with '/' ? base_path ~ src_url : src_url) ~ ' ' ~ src_rest]) %} + {% endif %} + {% endfor %} {% for src in image.sources.webp|default([]) %} {% set src_str = src is iterable ? (src.url|default('') ~ (src.width ? (' ' ~ src.width ~ 'w') : '')) : src %} {% set src_parts = src_str|split(' ') %} @@ -481,6 +492,9 @@ {% if srcset_avif %} {% endif %} + {% if srcset_jxl %} + + {% endif %} {% if srcset_webp %} {% endif %} diff --git a/app/Views/frontend/home_parallax.twig b/app/Views/frontend/home_parallax.twig index aed40de8..1166a896 100644 --- a/app/Views/frontend/home_parallax.twig +++ b/app/Views/frontend/home_parallax.twig @@ -154,6 +154,7 @@ body { {% set raw_path = image.fallback_src|default('') %} {% set img_src = raw_path != '' and raw_path starts with '/' ? base_path ~ raw_path : raw_path %} {% set variants_avif = [] %} + {% set variants_jxl = [] %} {% set variants_webp = [] %} {% set variants_jpg = [] %} {% set best_jpg_path = img_src %} @@ -162,6 +163,8 @@ body { {% set variant_url = variant.path starts with '/' ? base_path ~ variant.path : variant.path %} {% if variant.format == 'avif' %} {% set variants_avif = variants_avif|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} + {% elseif variant.format == 'jxl' %} + {% set variants_jxl = variants_jxl|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'webp' %} {% set variants_webp = variants_webp|merge([variant_url ~ ' ' ~ variant.width ~ 'w']) %} {% elseif variant.format == 'jpg' %} @@ -176,6 +179,9 @@ body { {% if variants_avif %} {% endif %} + {% if variants_jxl %} + + {% endif %} {% if variants_webp %} {% endif %} From 6f1f078e38a14889b0874839e6e4bb6e981fecdb Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 17 Jun 2026 17:22:32 +0200 Subject: [PATCH 05/11] test: protected-media security suite + 1.4.13 migration coverage - Add MediaAccessSecurityTest: 4 comprehensive tests locking in the access-control invariants for NSFW-only, password-only, combined NSFW+password albums, plus the cross-cutting boundaries (per-album scoping, unpublished 404, path-traversal rejection, downloads-disabled original fallback). Each asserts the core invariant: a gated visitor may receive a generic blur placeholder but NEVER the real sharp variant bytes, while an authorized viewer gets the real bytes from no-store private storage. Access-control code is byte-identical to main; these tests guard it against regression. - MigrationsTest: seed image_variants in its pre-1.4.13 shape so the new jxl-widening migration's table rebuild applies, and assert format='jxl' is accepted post-migration. --- tests/Database/MigrationsTest.php | 24 +- tests/Services/MediaAccessSecurityTest.php | 358 +++++++++++++++++++++ 2 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 tests/Services/MediaAccessSecurityTest.php diff --git a/tests/Database/MigrationsTest.php b/tests/Database/MigrationsTest.php index 3f802b53..b7db9fa6 100644 --- a/tests/Database/MigrationsTest.php +++ b/tests/Database/MigrationsTest.php @@ -67,7 +67,21 @@ private function freshBaseDb(): PDO CREATE TABLE analytics_pageviews (id INTEGER PRIMARY KEY, page_type TEXT, viewed_at TEXT); CREATE TABLE analytics_events (id INTEGER PRIMARY KEY, event_type TEXT, occurred_at TEXT); CREATE TABLE albums (id INTEGER PRIMARY KEY, title TEXT); - CREATE TABLE images (id INTEGER PRIMARY KEY, album_id INTEGER);' + CREATE TABLE images (id INTEGER PRIMARY KEY, album_id INTEGER); + -- image_variants in its pre-1.4.13 shape (format CHECK without + -- jxl); the 1.4.13 migration rebuilds it to widen the CHECK. + CREATE TABLE image_variants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + image_id INTEGER NOT NULL, + variant TEXT NOT NULL, + format TEXT NOT NULL CHECK(format IN ("avif", "webp", "jpg")), + path TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + size_bytes INTEGER NOT NULL, + UNIQUE(image_id, variant, format), + FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE + );' ); return $db; } @@ -109,6 +123,14 @@ public function testAllSqliteMigrationsApplyInOrder(): void // 1.4.0 — collections tables. $tables = $db->query("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('collections','collection_images')")->fetchColumn(); self::assertSame(2, (int) $tables, '1.4.0 collections tables'); + + // 1.4.13 — image_variants.format CHECK widened to admit 'jxl'. + // Pre-migration the row would violate the CHECK; post-migration it + // must insert cleanly. A seeded image FK target is required. + $db->exec("INSERT INTO images(id, album_id) VALUES (1, NULL)"); + $db->exec("INSERT INTO image_variants(image_id, variant, format, path, width, height, size_bytes) VALUES (1, 'md', 'jxl', '/m/1_md.jxl', 1200, 800, 1234)"); + $jxl = $db->query("SELECT COUNT(*) FROM image_variants WHERE format='jxl'")->fetchColumn(); + self::assertSame(1, (int) $jxl, "1.4.13 must allow format='jxl' in image_variants"); } public function testMigrationsAreIdempotent(): void diff --git a/tests/Services/MediaAccessSecurityTest.php b/tests/Services/MediaAccessSecurityTest.php new file mode 100644 index 00000000..18de2bf6 --- /dev/null +++ b/tests/Services/MediaAccessSecurityTest.php @@ -0,0 +1,358 @@ +nextId = random_int(7_000_000, 7_900_000); + $this->dbFile = sys_get_temp_dir() . '/cimaise_media_security_' . uniqid('', true) . '.sqlite'; + $this->db = new Database(null, null, $this->dbFile, null, null, 'utf8mb4', 'utf8mb4_unicode_ci', true); + $this->db->pdo()->exec( + 'CREATE TABLE albums ( + id INTEGER PRIMARY KEY, + slug TEXT, + is_published INTEGER NOT NULL DEFAULT 1, + is_nsfw INTEGER NOT NULL DEFAULT 0, + password_hash TEXT, + allow_downloads INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE images ( + id INTEGER PRIMARY KEY, + album_id INTEGER NOT NULL, + original_path TEXT, + mime TEXT + ); + CREATE TABLE image_variants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + image_id INTEGER NOT NULL, + variant TEXT NOT NULL, + format TEXT NOT NULL, + path TEXT NOT NULL, + width INTEGER, + height INTEGER, + size_bytes INTEGER + );' + ); + } + + protected function tearDown(): void + { + $_SESSION = []; + $_COOKIE = []; + unset($this->db); + // Single cleanup site for every path THIS test created: the test + // media fixtures (public/media + storage/protected-media) and its own + // temp sqlite files. None are user input. + $cleanup = array_merge( + $this->createdFiles, + [$this->dbFile, $this->dbFile . '-wal', $this->dbFile . '-shm'] + ); + foreach ($cleanup as $file) { + // nosemgrep: test-fixture cleanup; $file is a path this test itself + // created (temp sqlite / public/media / storage/protected-media), never user input. + @unlink($file); // nosemgrep + } + } + + // ── TEST 1 — NSFW-only album ────────────────────────────────────────── + + public function testNsfwOnlyAlbumGatesSharpVariantButAllowsBlurAndGrantsOnConsent(): void + { + $imageId = $this->insertImage(albumId: 1, nsfw: true); + $sharpPublic = $this->writeVariant($imageId, 'sm', 'jpg'); + $sharpBytes = (string) file_get_contents($sharpPublic); + + // (a) Anonymous visitor, no consent → the sharp variant is gated. The + // controller may answer with a generic blur placeholder (200) rather + // than a 403, but it MUST NOT return the real sharp bytes. + $denied = $this->servePublic($imageId, 'sm'); + $this->assertNoSharpLeak($denied, $sharpBytes, 'NSFW sharp variant without consent'); + + // (b) The blur variant backs the cover image and must stay reachable + // without consent (and stay public/cacheable). + $this->writeVariant($imageId, 'blur', 'jpg'); + $blur = $this->servePublic($imageId, 'blur'); + self::assertSame(200, $blur->getStatusCode(), 'blur variant must be served without consent'); + self::assertStringStartsWith('public,', $blur->getHeaderLine('Cache-Control'), 'blur is a public cover asset'); + + // (c) After NSFW consent, the sharp variant is served — quarantined to + // private storage and never cacheable / never indexable. + $_SESSION['nsfw_confirmed_global'] = true; + $granted = $this->servePublic($imageId, 'sm'); + self::assertSame(200, $granted->getStatusCode()); + self::assertStringContainsString('no-store', $granted->getHeaderLine('Cache-Control')); + self::assertStringContainsString('noimageindex', $granted->getHeaderLine('X-Robots-Tag')); + self::assertFileDoesNotExist($sharpPublic, 'sharp NSFW variant must be quarantined out of public/media'); + self::assertFileExists($this->privatePath("{$imageId}_sm.jpg"), 'sharp NSFW variant lives in protected-media'); + } + + // ── TEST 2 — password-only album ────────────────────────────────────── + + public function testPasswordOnlyAlbumGatesUntilSessionGrantedAndExpires(): void + { + $imageId = $this->insertImage(albumId: 1, passwordHash: password_hash('s3cret', PASSWORD_DEFAULT)); + $sharpPublic = $this->writeVariant($imageId, 'sm', 'jpg'); + $sharpBytes = (string) file_get_contents($sharpPublic); + + // (a) No unlock → gated; the real bytes are never served. + $denied = $this->serveProtected($imageId, 'sm'); + $this->assertNoSharpLeak($denied, $sharpBytes, 'password sharp variant before unlock'); + + // (b) Valid unlock session → served, private + no-store. + $_SESSION['album_access'][1] = time(); + $granted = $this->serveProtected($imageId, 'sm'); + self::assertSame(200, $granted->getStatusCode()); + self::assertStringContainsString('no-store', $granted->getHeaderLine('Cache-Control')); + self::assertStringContainsString('no-cache', $granted->getHeaderLine('Pragma')); + self::assertSame($sharpBytes, (string) $granted->getBody(), 'authorized viewer receives the real variant'); + + // (c) Expired unlock (older than the 24h window) → gated again, and + // the stale session entry is purged by the access check. + $_SESSION['album_access'][1] = time() - 86_400 - 10; + $expired = $this->serveProtected($imageId, 'sm'); + $this->assertNoSharpLeak($expired, $sharpBytes, 'expired unlock re-gates the album'); + self::assertArrayNotHasKey(1, $_SESSION['album_access'] ?? [], 'stale unlock entry is purged'); + } + + // ── TEST 3 — combined NSFW + password album ─────────────────────────── + + public function testCombinedAlbumRequiresBothGatesConjunctively(): void + { + $imageId = $this->insertImage( + albumId: 1, + nsfw: true, + passwordHash: password_hash('s3cret', PASSWORD_DEFAULT) + ); + $sharpPublic = $this->writeVariant($imageId, 'sm', 'jpg'); + $sharpBytes = (string) file_get_contents($sharpPublic); + + // (a) Nothing cleared → gated, no sharp bytes. + $this->assertNoSharpLeak($this->serveProtected($imageId, 'sm'), $sharpBytes, 'both gates closed'); + + // (b) Password cleared, NSFW NOT → still gated (password is checked + // first, then NSFW; clearing one is not enough). + $_SESSION['album_access'][1] = time(); + $this->assertNoSharpLeak($this->serveProtected($imageId, 'sm'), $sharpBytes, 'password alone is not enough'); + + // (c) NSFW cleared, password NOT → still gated. + $_SESSION = []; + $_SESSION['nsfw_confirmed_global'] = true; + $this->assertNoSharpLeak($this->serveProtected($imageId, 'sm'), $sharpBytes, 'NSFW consent alone is not enough'); + + // (d) Both cleared → served, private + no-store, real bytes. + $_SESSION['album_access'][1] = time(); + $granted = $this->serveProtected($imageId, 'sm'); + self::assertSame(200, $granted->getStatusCode(), 'both gates cleared → serve'); + self::assertStringContainsString('no-store', $granted->getHeaderLine('Cache-Control')); + self::assertStringContainsString('noimageindex', $granted->getHeaderLine('X-Robots-Tag')); + self::assertSame($sharpBytes, (string) $granted->getBody(), 'fully-authorized viewer receives the real variant'); + } + + // ── TEST 4 — cross-cutting security boundaries ──────────────────────── + + public function testAccessBoundariesScopingUnpublishedTraversalAndDownloads(): void + { + // Two distinct protected albums. + $imgA = $this->insertImage(albumId: 1, passwordHash: password_hash('a', PASSWORD_DEFAULT)); + $imgB = $this->insertImage(albumId: 2, passwordHash: password_hash('b', PASSWORD_DEFAULT)); + $sharpA = $this->writeVariant($imgA, 'sm', 'jpg'); + $sharpB = $this->writeVariant($imgB, 'sm', 'jpg'); + $sharpBytesA = (string) file_get_contents($sharpA); + $sharpBytesB = (string) file_get_contents($sharpB); + + // (a) Per-album scoping: an unlock token for album 1 serves album 1 + // but grants NOTHING for album 2 (its sharp bytes never leak). + $_SESSION['album_access'][1] = time(); + $servedA = $this->serveProtected($imgA, 'sm'); + self::assertSame(200, $servedA->getStatusCode(), 'album 1 token serves album 1'); + self::assertSame($sharpBytesA, (string) $servedA->getBody(), 'album 1 viewer gets album 1 bytes'); + $this->assertNoSharpLeak($this->serveProtected($imgB, 'sm'), $sharpBytesB, 'album 1 token must NOT serve album 2'); + + // (b) Unpublished album → 404 even with a valid unlock token. + $imgC = $this->insertImage(albumId: 3, passwordHash: password_hash('c', PASSWORD_DEFAULT), published: false); + $this->writeVariant($imgC, 'sm', 'jpg'); + $_SESSION['album_access'][3] = time(); + self::assertSame(404, $this->serveProtected($imgC, 'sm')->getStatusCode(), 'unpublished album is 404 even when unlocked'); + + // (c) Path traversal through the public route is rejected by the + // filename whitelist (never resolves outside public/media). + $traversal = $this->controller()->servePublic( + (new ServerRequestFactory())->createServerRequest('GET', '/media/..%2f..%2f..%2fetc%2fpasswd'), + new Response(), + ['path' => '../../../etc/passwd'] + ); + self::assertGreaterThanOrEqual(400, $traversal->getStatusCode(), 'traversal path must not resolve to a file'); + + // (d) allow_downloads=0: when the sharp variant is missing, the + // controller must NOT hand the full-resolution original to a viewer + // (that would bypass the downloads setting). + $imgD = $this->insertImage(albumId: 4, passwordHash: password_hash('d', PASSWORD_DEFAULT), allowDownloads: false); + // note: no 'lg' variant written → triggers the original-fallback path + $_SESSION['album_access'][4] = time(); + $noDownload = $this->serveProtected($imgD, 'lg'); + self::assertSame(403, $noDownload->getStatusCode(), 'downloads-disabled album must not serve the original as a variant fallback'); + } + + // ── helpers ─────────────────────────────────────────────────────────── + + /** + * The core security invariant: a gated response may be a generic blur + * placeholder (200) or a 403, but it must NEVER contain the real sharp + * variant bytes. We compare the response body against the known sharp + * bytes captured before the request. + */ + private function assertNoSharpLeak( + \Psr\Http\Message\ResponseInterface $response, + string $sharpBytes, + string $scenario + ): void { + $body = (string) $response->getBody(); + self::assertNotSame( + $sharpBytes, + $body, + "{$scenario}: the real sharp variant bytes must never reach an ungated visitor" + ); + } + + private function servePublic(int $imageId, string $variant, string $format = 'jpg') + { + return $this->controller()->servePublic( + (new ServerRequestFactory())->createServerRequest('GET', "/media/{$imageId}_{$variant}.{$format}"), + new Response(), + ['path' => "{$imageId}_{$variant}.{$format}"] + ); + } + + private function serveProtected(int $imageId, string $variant, string $format = 'jpg') + { + return $this->controller()->serveProtected( + (new ServerRequestFactory())->createServerRequest('GET', "/media/protected/{$imageId}/{$variant}.{$format}"), + new Response(), + ['id' => $imageId, 'variant' => $variant, 'format' => $format] + ); + } + + private function insertImage( + int $albumId = 1, + bool $nsfw = false, + ?string $passwordHash = null, + bool $published = true, + bool $allowDownloads = true + ): int { + $imageId = $this->nextId++; + $albumStmt = $this->db->pdo()->prepare( + 'INSERT OR IGNORE INTO albums (id, slug, is_published, is_nsfw, password_hash, allow_downloads) + VALUES (?, ?, ?, ?, ?, ?)' + ); + $albumStmt->execute([ + $albumId, + 'album-' . $albumId, + $published ? 1 : 0, + $nsfw ? 1 : 0, + $passwordHash, + $allowDownloads ? 1 : 0, + ]); + + // original_path points at a file that does not exist, so the denial + // path cannot synthesize a blur on-demand and deterministically 403s. + $imageStmt = $this->db->pdo()->prepare( + 'INSERT INTO images (id, album_id, original_path, mime) VALUES (?, ?, ?, ?)' + ); + $imageStmt->execute([$imageId, $albumId, '/storage/originals/does-not-exist.jpg', 'image/jpeg']); + return $imageId; + } + + private function writeVariant(int $imageId, string $variant, string $format): string + { + $dbPath = "/media/{$imageId}_{$variant}.{$format}"; + $path = $this->writeJpeg($this->publicPath(basename($dbPath))); + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO image_variants (image_id, variant, format, path, width, height, size_bytes) + VALUES (?, ?, ?, ?, 16, 16, ?)' + ); + $stmt->execute([$imageId, $variant, $format, $dbPath, filesize($path)]); + return $path; + } + + private function writeJpeg(string $path): string + { + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0775, true); + } + if (!function_exists('imagecreatetruecolor') || !function_exists('imagejpeg')) { + self::markTestSkipped('GD JPEG support is required for media streaming tests'); + } + $image = imagecreatetruecolor(16, 16); + $color = imagecolorallocate($image, 20, 40, 80); + imagefilledrectangle($image, 0, 0, 15, 15, $color); + imagejpeg($image, $path, 85); + imagedestroy($image); + $this->createdFiles[] = $path; + return $path; + } + + private function controller(): MediaController + { + return new MediaController($this->db, new UploadService($this->db)); + } + + private function publicPath(string $basename): string + { + return dirname(__DIR__, 2) . '/public/media/' . $basename; + } + + private function privatePath(string $basename): string + { + $path = dirname(__DIR__, 2) . '/storage/protected-media/' . $basename; + $this->createdFiles[] = $path; + return $path; + } +} From 84d44cd1ded1ec6fc140a676163fed6b24d4b9d7 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 17 Jun 2026 17:28:36 +0200 Subject: [PATCH 06/11] feat(admin): add "Back to Pages" arrow on page editors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home/about/cookie/license/privacy editors under /admin/pages had no way back to the page list except the browser button. Add a "← Back" btn-secondary linking to /admin/pages in each editor's header action group, matching the pattern already used in pages/galleries.twig (trans key admin.pages.back, already present in both locales). --- app/Views/admin/pages/about.twig | 5 ++++- app/Views/admin/pages/cookie.twig | 5 ++++- app/Views/admin/pages/home.twig | 5 ++++- app/Views/admin/pages/license.twig | 5 ++++- app/Views/admin/pages/privacy.twig | 5 ++++- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/app/Views/admin/pages/about.twig b/app/Views/admin/pages/about.twig index 2695247a..773a1840 100644 --- a/app/Views/admin/pages/about.twig +++ b/app/Views/admin/pages/about.twig @@ -6,7 +6,10 @@

{{ trans('admin.pages.title') }}

{{ trans('admin.pages.about.edit')|raw }}

- {{ trans('admin.pages.view_page') }} +
diff --git a/app/Views/admin/pages/cookie.twig b/app/Views/admin/pages/cookie.twig index 84a46f6f..ef45853b 100644 --- a/app/Views/admin/pages/cookie.twig +++ b/app/Views/admin/pages/cookie.twig @@ -6,7 +6,10 @@

{{ trans('admin.pages.title') }}

{{ trans('admin.pages.cookie.edit')|raw }}

- {{ trans('admin.pages.view_page') }} +
diff --git a/app/Views/admin/pages/home.twig b/app/Views/admin/pages/home.twig index e7c583e5..7635edd6 100644 --- a/app/Views/admin/pages/home.twig +++ b/app/Views/admin/pages/home.twig @@ -7,7 +7,10 @@

{{ trans('admin.pages.title') }}

{{ trans('admin.pages.home.edit')|raw }}

- {{ trans('admin.pages.home.view_page') }} +
diff --git a/app/Views/admin/pages/license.twig b/app/Views/admin/pages/license.twig index 2f548519..a372aca9 100644 --- a/app/Views/admin/pages/license.twig +++ b/app/Views/admin/pages/license.twig @@ -6,7 +6,10 @@

{{ trans('admin.pages.title') }}

{{ trans('admin.pages.license.edit')|raw }}

- {{ trans('admin.pages.view_page') }} +
diff --git a/app/Views/admin/pages/privacy.twig b/app/Views/admin/pages/privacy.twig index 90598148..9a36d990 100644 --- a/app/Views/admin/pages/privacy.twig +++ b/app/Views/admin/pages/privacy.twig @@ -6,7 +6,10 @@

{{ trans('admin.pages.title') }}

{{ trans('admin.pages.privacy.edit')|raw }}

- {{ trans('admin.pages.view_page') }} +
From e8d651fa0906affb43e0976a8b4077cf2cc3a12c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 17 Jun 2026 17:40:46 +0200 Subject: [PATCH 07/11] feat(media): serve JPEG-XL variants end-to-end (completes #109 F013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The F013 template work emitted URLs, but the serving layer rejected the .jxl format, so those URLs 404'd on every album (all /media/ traffic routes through MediaController). Wire jxl through the full serve path: - EXT_TO_MIME gains jxl => image/jxl. - serveProtected format whitelist, servePublic filename regex + allowedMimes, serveResolvedFile + serveStaticFile allowedMimes all accept jxl. - ProtectedMediaStorage::mediaBasename accepts .jxl so protected jxl variants resolve/quarantine like the others. - Strict DB-path MIME gate: libmagic frequently can't identify JPEG-XL, which would spuriously fail the magic-byte cross-check. Verify the JXL signature directly (bare codestream 0xFF0A or the ISO-BMFF "JXL " box) instead of trusting finfo — keeps the strict guarantee on every libmagic version. A .jxl file whose bytes aren't JXL is still rejected. Also harden ProtectedMediaStorage deletions: route all 7 unlink() sites through a new confinedUnlink() that realpath-confines the target to the public/media or storage/protected-media root (defence-in-depth on top of mediaBasename()'s filename whitelist). Adds a JPEG-XL serving test (valid jxl → 200 image/jxl; spoofed bytes → 403) to MediaAccessSecurityTest. --- app/Controllers/Frontend/MediaController.php | 45 +++++++++++++++++--- app/Services/ProtectedMediaStorage.php | 43 +++++++++++++++---- tests/Services/MediaAccessSecurityTest.php | 42 ++++++++++++++++++ 3 files changed, 116 insertions(+), 14 deletions(-) diff --git a/app/Controllers/Frontend/MediaController.php b/app/Controllers/Frontend/MediaController.php index d423c3ba..9e54a74b 100644 --- a/app/Controllers/Frontend/MediaController.php +++ b/app/Controllers/Frontend/MediaController.php @@ -33,6 +33,7 @@ class MediaController extends BaseController 'jpeg' => 'image/jpeg', 'webp' => 'image/webp', 'avif' => 'image/avif', + 'jxl' => 'image/jxl', 'png' => 'image/png', 'gif' => 'image/gif', 'tif' => 'image/tiff', @@ -88,6 +89,14 @@ private function mimeFromExtension(string $realPath, bool $strict = false): ?str return $extMime; } + // JPEG-XL (#109): many libmagic builds don't recognize JXL, so a + // finfo_file() cross-check would spuriously fail the strict gate. + // Verify the JXL magic bytes directly instead — still a real + // magic-byte check (not extension trust), just one finfo can't do. + if ($extMime === 'image/jxl') { + return (!$strict || self::looksLikeJxl($realPath)) ? 'image/jxl' : null; + } + $finfo = finfo_open(FILEINFO_MIME_TYPE); if ($finfo === false) { // In strict mode we must be able to verify; without finfo we cannot. @@ -113,6 +122,30 @@ private function mimeFromExtension(string $realPath, bool $strict = false): ?str return $extMime ?? $detected; } + /** + * Verify a file is a genuine JPEG-XL stream by its magic bytes, independent + * of libmagic (which often lacks JXL support). Accepts both encodings: + * - bare codestream: starts with 0xFF 0x0A + * - ISO-BMFF container: the 12-byte "JXL " signature box + */ + private static function looksLikeJxl(string $realPath): bool + { + $fh = @fopen($realPath, 'rb'); + if ($fh === false) { + return false; + } + $head = fread($fh, 12); + fclose($fh); + if ($head === false || strlen($head) < 2) { + return false; + } + if (substr($head, 0, 2) === "\xFF\x0A") { + return true; // bare codestream + } + return strlen($head) >= 12 + && $head === "\x00\x00\x00\x0C\x4A\x58\x4C\x20\x0D\x0A\x87\x0A"; // ISO-BMFF box + } + /** * Stream a file into the response body. * @@ -336,7 +369,7 @@ public function serveProtected(Request $request, Response $response, array $args } // Validate format - if (!\in_array($format, ['jpg', 'webp', 'avif'], true)) { + if (!\in_array($format, ['jpg', 'webp', 'avif', 'jxl'], true)) { return $response->withStatus(400); } @@ -463,7 +496,7 @@ public function serveProtected(Request $request, Response $response, array $args // a finfo_file() magic-byte cross-check because the path is DB-sourced. $detectedMime = $this->mimeFromExtension($realPath, strict: true); - $allowedMimes = ['image/jpeg', 'image/webp', 'image/avif', 'image/png']; + $allowedMimes = ['image/jpeg', 'image/webp', 'image/avif', 'image/jxl', 'image/png']; if ($detectedMime === null || !\in_array($detectedMime, $allowedMimes, true)) { return $response->withStatus(403); } @@ -627,7 +660,7 @@ public function servePublic(Request $request, Response $response, array $args): // Parse filename to extract image ID // Format: {imageId}_{variant}.{format} or {imageId}_blur.{format} $filename = basename((string) $path); - if (!preg_match('/^(\d+)_([a-z0-9_-]+)\.(jpg|webp|avif|png)$/i', $filename, $matches)) { + if (!preg_match('/^(\d+)_([a-z0-9_-]+)\.(jpg|webp|avif|jxl|png)$/i', $filename, $matches)) { // Any numeric-prefixed media filename could be an album variant // from an older/custom generator. Never let it fall through to // unauthenticated static serving merely because its shape changed. @@ -642,7 +675,7 @@ public function servePublic(Request $request, Response $response, array $args): $imageId = (int)$matches[1]; $variant = strtolower($matches[2]); - $format = strtolower($matches[3]); // regex-validated: jpg|webp|avif|png + $format = strtolower($matches[3]); // regex-validated: jpg|webp|avif|jxl|png // Get image and album info $pdo = $this->db->pdo(); @@ -819,7 +852,7 @@ private function serveResolvedFile( bool $protected ): Response { $detectedMime = $this->mimeFromExtension($realPath, strict: true); - if ($detectedMime === null || !\in_array($detectedMime, ['image/jpeg', 'image/webp', 'image/avif', 'image/png'], true)) { + if ($detectedMime === null || !\in_array($detectedMime, ['image/jpeg', 'image/webp', 'image/avif', 'image/jxl', 'image/png'], true)) { return $response->withStatus(403); } @@ -886,7 +919,7 @@ private function serveStaticFile(Request $request, Response $response, string $r // Validate MIME type via fast extension lookup; finfo only if unknown extension $detectedMime = $this->mimeFromExtension($realPath); - $allowedMimes = ['image/jpeg', 'image/webp', 'image/avif', 'image/png', 'image/gif']; + $allowedMimes = ['image/jpeg', 'image/webp', 'image/avif', 'image/jxl', 'image/png', 'image/gif']; if ($detectedMime === null || !\in_array($detectedMime, $allowedMimes, true)) { return $response->withStatus(403); } diff --git a/app/Services/ProtectedMediaStorage.php b/app/Services/ProtectedMediaStorage.php index dc09c38a..a5346d04 100644 --- a/app/Services/ProtectedMediaStorage.php +++ b/app/Services/ProtectedMediaStorage.php @@ -204,8 +204,8 @@ public function deleteVariantCopies(string $dbPath): bool if ($basename === null) { return false; } - @unlink($this->publicDir . '/' . $basename); - @unlink($this->privateDir . '/' . $basename); + $this->confinedUnlink($this->publicDir . '/' . $basename); + $this->confinedUnlink($this->privateDir . '/' . $basename); return !is_file($this->publicDir . '/' . $basename) && !is_file($this->privateDir . '/' . $basename); } @@ -229,7 +229,7 @@ private function hasPublicReference(string $dbPath): bool private function quarantineFile(string $publicPath, string $privatePath): void { if (is_file($privatePath)) { - @unlink($publicPath); + $this->confinedUnlink($publicPath); return; } if (!is_file($publicPath)) { @@ -244,21 +244,21 @@ private function quarantineFile(string $publicPath, string $privatePath): void if (!$moved) { $moved = @copy($publicPath, $privatePath); if ($moved) { - @unlink($publicPath); + $this->confinedUnlink($publicPath); } } // Security takes precedence over availability. Never leave a sharp // protected variant under public/ after a failed relocation. if (!$moved || !is_file($privatePath)) { - @unlink($publicPath); + $this->confinedUnlink($publicPath); } } private function moveToPublic(string $privatePath, string $publicPath): void { if (is_file($publicPath)) { - @unlink($privatePath); + $this->confinedUnlink($privatePath); return; } if (!is_file($privatePath)) { @@ -268,10 +268,37 @@ private function moveToPublic(string $privatePath, string $publicPath): void @mkdir($this->publicDir, 0775, true); } if (!@rename($privatePath, $publicPath) && @copy($privatePath, $publicPath)) { - @unlink($privatePath); + $this->confinedUnlink($privatePath); } } + /** + * Delete a file only when it resolves INSIDE one of this service's media + * roots (public/media or storage/protected-media). Defence-in-depth on top + * of mediaBasename()'s filename whitelist: every deletion path is realpath- + * resolved and containment-checked, so neither a crafted DB value nor a + * future caller that builds a path some other way can ever delete a file + * outside the media directories. + */ + private function confinedUnlink(string $path): void + { + $real = realpath($path); + if ($real === false) { + return; // nothing on disk to remove + } + $pub = realpath($this->publicDir); + $priv = realpath($this->privateDir); + $inPublic = $pub !== false && str_starts_with($real, $pub . DIRECTORY_SEPARATOR); + $inPrivate = $priv !== false && str_starts_with($real, $priv . DIRECTORY_SEPARATOR); + if (!$inPublic && !$inPrivate) { + return; + } + // nosemgrep: $real is realpath-resolved and verified to live under the + // public/media or storage/protected-media root immediately above — it + // cannot traverse outside the media dirs, and is never raw user input. + @unlink($real); // nosemgrep + } + private function mediaBasename(string $dbPath): ?string { $normalized = str_replace('\\', '/', trim($dbPath)); @@ -288,7 +315,7 @@ private function mediaBasename(string $dbPath): ?string } $basename = basename($normalized); - return preg_match('/^\d+_[a-z0-9_-]+\.(?:jpg|jpeg|webp|avif|png)$/i', $basename) + return preg_match('/^\d+_[a-z0-9_-]+\.(?:jpg|jpeg|webp|avif|jxl|png)$/i', $basename) ? $basename : null; } diff --git a/tests/Services/MediaAccessSecurityTest.php b/tests/Services/MediaAccessSecurityTest.php index 18de2bf6..2695c254 100644 --- a/tests/Services/MediaAccessSecurityTest.php +++ b/tests/Services/MediaAccessSecurityTest.php @@ -241,8 +241,50 @@ public function testAccessBoundariesScopingUnpublishedTraversalAndDownloads(): v self::assertSame(403, $noDownload->getStatusCode(), 'downloads-disabled album must not serve the original as a variant fallback'); } + // ── TEST 5 — JPEG-XL serving end-to-end (#109) ──────────────────────── + + public function testJpegXlVariantServesWithImageJxlMimeAndStrictMagicCheck(): void + { + // (a) A genuine .jxl variant of a public album serves with the correct + // Content-Type. The strict DB-path MIME gate must accept it via the + // JXL magic bytes (libmagic often can't), not the extension alone. + $imageId = $this->insertImage(albumId: 1); + $jxlBytes = "\xFF\x0A" . str_repeat("\x00", 64); // bare JXL codestream signature + $this->writeRawVariant($imageId, 'md', 'jxl', $jxlBytes); + + $served = $this->servePublic($imageId, 'md', 'jxl'); + self::assertSame(200, $served->getStatusCode(), 'public JPEG-XL variant must serve, not 404'); + self::assertSame('image/jxl', $served->getHeaderLine('Content-Type'), 'served with the JPEG-XL MIME type'); + self::assertSame($jxlBytes, (string) $served->getBody(), 'serves the real jxl bytes'); + + // (b) A file with a .jxl extension but NON-JXL magic bytes must be + // rejected by the strict magic-byte cross-check (extension is never + // trusted on DB-sourced paths) → 403, not served as image/jxl. + $imageId2 = $this->insertImage(albumId: 2); + $this->writeRawVariant($imageId2, 'md', 'jxl', "\xFF\xD8\xFF\xE0" . str_repeat("\x00", 64)); // JPEG magic, .jxl name + $spoofed = $this->servePublic($imageId2, 'md', 'jxl'); + self::assertSame(403, $spoofed->getStatusCode(), 'a .jxl file whose bytes are not JXL must be rejected'); + } + // ── helpers ─────────────────────────────────────────────────────────── + private function writeRawVariant(int $imageId, string $variant, string $format, string $bytes): string + { + $dbPath = "/media/{$imageId}_{$variant}.{$format}"; + $path = $this->publicPath(basename($dbPath)); + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0775, true); + } + file_put_contents($path, $bytes); + $this->createdFiles[] = $path; + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO image_variants (image_id, variant, format, path, width, height, size_bytes) + VALUES (?, ?, ?, ?, 16, 16, ?)' + ); + $stmt->execute([$imageId, $variant, $format, $dbPath, strlen($bytes)]); + return $path; + } + /** * The core security invariant: a gated response may be a generic blur * placeholder (200) or a 403, but it must NEVER contain the real sharp From 2a8cc51c9974533e2bc80003d04f799cd4cd14f8 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 18 Jun 2026 00:30:35 +0200 Subject: [PATCH 08/11] feat(imaging): generate JPEG-XL via cjxl when libvips lacks libjxl (#109) JPEG-XL generation previously required a libvips build with libjxl, which is rarer than the standalone cjxl (libjxl reference) encoder. Add a cjxl fallback so jxl variants are actually produced on far more hosts: - ImageEngine::capabilities()['jxl_write'] is now true when libvips can write jxl OR (Imagick is present AND cjxl is on PATH). - ImageEngine::encode() routes jxl to a new encodeJxlViaCjxl() when vips is absent or its build can't write jxl: Imagick produces the resized, metadata-stripped PNG intermediate, cjxl transcodes it to .jxl (argv proc_open, no shell). - ImagesGenerateCommand derives jxl variant dimensions from the source aspect ratio (getimagesize() can't read .jxl, which would otherwise store height=0). Verified end-to-end on a cjxl host: images:generate now emits real .jxl variants (FF0A codestream, correct dims), MediaController serves them as image/jxl, and NSFW/password albums quarantine them to protected-media. Adds ImageEngineJxlTest (skips where no jxl encoder exists). --- app/Services/Imaging/ImageEngine.php | 86 +++++++++++++++++++++++++-- app/Tasks/ImagesGenerateCommand.php | 18 +++++- tests/Services/ImageEngineJxlTest.php | 50 ++++++++++++++++ 3 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 tests/Services/ImageEngineJxlTest.php diff --git a/app/Services/Imaging/ImageEngine.php b/app/Services/Imaging/ImageEngine.php index f1eccd61..2cfaa71c 100644 --- a/app/Services/Imaging/ImageEngine.php +++ b/app/Services/Imaging/ImageEngine.php @@ -51,7 +51,10 @@ public static function capabilities(): array // Imagick HEIC delegate. 'heif_read' => ($vips && self::vipsCanLoad('probe.heic')) || self::imagickSupports('HEIC'), 'avif_write' => ($vips && self::vipsCanWrite('.avif')) || self::imagickSupports('AVIF'), - 'jxl_write' => $vips && self::vipsCanWrite('.jxl'), + // JPEG-XL: libvips+libjxl when available, otherwise the standalone + // cjxl (libjxl) CLI with Imagick producing the resized intermediate. + 'jxl_write' => ($vips && self::vipsCanWrite('.jxl')) + || (class_exists(\Imagick::class) && self::binaryExists('cjxl')), // Post-encode optimizer (only jpegoptim is actually invoked, for // jpeg variants; webp/avif/jxl are emitted at target quality and // PNG is never produced, so no other optimizer is detected). @@ -84,13 +87,20 @@ public static function encode( int $quality, bool $strip = true ): bool { + $targetW = max(1, $targetW); + $quality = max(1, min(100, $quality)); + if (!self::vipsAvailable()) { + // No libvips. JPEG-XL has no Imagick/GD path in the callers, but the + // standalone cjxl (libjxl) encoder can still produce it from an + // Imagick-resized intermediate. Every other format falls through to + // the caller's existing Imagick/GD fallback (return false). + if ($format === 'jxl') { + return self::encodeJxlViaCjxl($src, $dest, $targetW, $quality, $strip); + } return false; } - $targetW = max(1, $targetW); - $quality = max(1, min(100, $quality)); - try { // thumbnail() shrinks on load (decodes only what's needed) — the // big memory/CPU win over decode-then-resize. @@ -120,6 +130,74 @@ public static function encode( 'format' => $format, 'error' => $e->getMessage(), ], 'imaging'); + // vips is present but couldn't write this format (e.g. a libvips + // build without libjxl). For JPEG-XL, try the standalone cjxl + // encoder before giving up — the Imagick/GD caller fallback can't + // emit jxl, so this is the only remaining route. + if ($format === 'jxl') { + return self::encodeJxlViaCjxl($src, $dest, $targetW, $quality, $strip); + } + return false; + } + } + + /** + * Encode a resized JPEG-XL variant via the standalone cjxl (libjxl) CLI, + * for hosts whose libvips lacks libjxl. Imagick produces the resized, + * metadata-stripped PNG intermediate; cjxl transcodes it to .jxl. Returns + * false when neither Imagick nor cjxl is usable so the caller can skip jxl. + */ + private static function encodeJxlViaCjxl(string $src, string $dest, int $targetW, int $quality, bool $strip): bool + { + if (!class_exists(\Imagick::class) || !self::binaryExists('cjxl')) { + return false; + } + $pngTmp = null; + try { + $im = new \Imagick(); + $im->readImage($src); + // Never upscale; 0 height preserves the aspect ratio. + if ($im->getImageWidth() > $targetW) { + $im->thumbnailImage($targetW, 0); + } + if ($strip) { + $im->stripImage(); + } + $im->setImageFormat('png'); + + $base = tempnam(sys_get_temp_dir(), 'cimaise_jxl_'); + if ($base === false) { + $im->clear(); + return false; + } + // cjxl picks the input codec by extension, so give the intermediate + // a .png suffix (tempnam can't set one). + $pngTmp = $base . '.png'; + @unlink($base); + $im->writeImage($pngTmp); + $im->clear(); + + if (!is_dir(dirname($dest))) { + @mkdir(dirname($dest), 0775, true); + } + // cjxl refuses to overwrite silently on some builds; clear first. + if (is_file($dest)) { + @unlink($dest); + } + // argv array → no shell (see run()); $quality is a clamped int and + // the paths are app-controlled, never interpolated into a command. + self::run(['cjxl', $pngTmp, $dest, '-q', (string) $quality]); + @unlink($pngTmp); + + return is_file($dest) && (int) filesize($dest) > 0; + } catch (\Throwable $e) { + if ($pngTmp !== null) { + @unlink($pngTmp); + } + Logger::warning('ImageEngine: cjxl encode failed', [ + 'src' => $src, + 'error' => $e->getMessage(), + ], 'imaging'); return false; } } diff --git a/app/Tasks/ImagesGenerateCommand.php b/app/Tasks/ImagesGenerateCommand.php index b97d3fb0..0bda7d6b 100644 --- a/app/Tasks/ImagesGenerateCommand.php +++ b/app/Tasks/ImagesGenerateCommand.php @@ -124,6 +124,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int continue; } + // Source dimensions, used to derive variant height for formats + // getimagesize() cannot read back from the generated file (JPEG-XL). + $srcDims = @getimagesize($src); + $srcW = (int)($srcDims[0] ?? 0); + $srcH = (int)($srcDims[1] ?? 0); + $existingStmt = $pdo->prepare('SELECT variant, format, path FROM image_variants WHERE image_id = ?'); $existingStmt->execute([$imageId]); $existingVariants = []; @@ -213,7 +219,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int // the whole run — log and continue with the next variant. try { $size = (int)filesize($dest); - [$w, $h] = getimagesize($dest) ?: [(int)$width, 0]; + $probe = @getimagesize($dest); + if ($probe !== false) { + [$w, $h] = $probe; + } else { + // getimagesize() can't read JPEG-XL: derive the + // dims from the source aspect ratio and the + // (never-upscaled) target width so height is + // never silently stored as 0. + $w = ($srcW > 0 && (int)$width > $srcW) ? $srcW : (int)$width; + $h = ($srcW > 0 && $srcH > 0) ? (int)round($w * $srcH / $srcW) : 0; + } $replaceKeyword = $this->db->replaceKeyword(); $stmt = $pdo->prepare(sprintf( '%s INTO image_variants(image_id, variant, format, path, width, height, size_bytes) VALUES(?,?,?,?,?,?,?)', diff --git a/tests/Services/ImageEngineJxlTest.php b/tests/Services/ImageEngineJxlTest.php new file mode 100644 index 00000000..99eab04e --- /dev/null +++ b/tests/Services/ImageEngineJxlTest.php @@ -0,0 +1,50 @@ + Date: Thu, 18 Jun 2026 00:46:53 +0200 Subject: [PATCH 09/11] fix: doc/message cleanups from walkthrough (F016/F017/F018/F029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment/message-only, no logic change: - F017: resizeWithImagick() comment described an inline Imagick body "below" that no longer exists after the F003 refactor — it now delegates to resizeWithImagickOnly(). Reworded to match. - F018: $allowed comment said "variants are jpg/webp/avif as usual"; jxl is now a real generated+served variant — updated to include it. - F029: the HEIC-unsupported RuntimeException leaked which imaging libraries are present ("no libheif / Imagick HEIC delegate") in the client JSON response. The client now gets a generic message; the detail is logged server-side via Logger::warning. Acceptance logic unchanged. - F016: strengthened the nosemgrep rationale on ImageEngine::run() — it's private static and every caller passes a constant binary name plus an app-controlled path, so a future user-influenced argv would be caught in review. --- app/Services/Imaging/ImageEngine.php | 8 +++++++- app/Services/UploadService.php | 10 ++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/Services/Imaging/ImageEngine.php b/app/Services/Imaging/ImageEngine.php index 2cfaa71c..e491af0d 100644 --- a/app/Services/Imaging/ImageEngine.php +++ b/app/Services/Imaging/ImageEngine.php @@ -341,7 +341,13 @@ private static function run(array $argv): void // nosemgrep: argv-array form bypasses the shell entirely; binary // names are hardcoded constants and the only dynamic element is a // filesystem path passed as a discrete argv element (never - // interpolated), so command injection is not possible. + // interpolated), so command injection is not possible. This is + // enforced by call-site discipline: run() is PRIVATE STATIC and + // every in-repo caller (encode→optimize's jpegoptim and + // encodeJxlViaCjxl's cjxl) passes a HARDCODED binary name plus an + // app-controlled (never user-supplied) path. Any new caller passing + // user-influenced argv would have to be added here and would be + // caught in review. $proc = @proc_open($argv, $descriptors, $pipes); // nosemgrep if (is_resource($proc)) { @proc_close($proc); diff --git a/app/Services/UploadService.php b/app/Services/UploadService.php index cf1645fe..16a12834 100644 --- a/app/Services/UploadService.php +++ b/app/Services/UploadService.php @@ -20,7 +20,7 @@ class UploadService // them (ImageEngine::capabilities()['heif_read']); validateImageFile() // gates on that so we never store an original we can't turn into web // variants. Originals keep their .heic/.heif extension; variants are jpg/ - // webp/avif as usual. + // webp/avif/jxl as usual. private array $allowed = [ 'image/jpeg' => '.jpg', 'image/png' => '.png', @@ -126,7 +126,8 @@ private function validateImageFile(string $filePath): string // so we never store an original we can't turn into web variants. $isHeif = $detectedMime === 'image/heic' || $detectedMime === 'image/heif'; if ($isHeif && !\App\Services\Imaging\ImageEngine::capabilities()['heif_read']) { - throw new RuntimeException('HEIC/HEIF is not supported on this server (no libheif / Imagick HEIC delegate).'); + Logger::warning('HEIC upload rejected: no libheif / Imagick HEIC delegate', ['mime' => $detectedMime], 'upload'); + throw new RuntimeException('HEIC/HEIF is not supported on this server.'); } // 4. Validate magic numbers (file header signatures) @@ -416,8 +417,9 @@ public function ingestAlbumUpload(int $albumId, array $file): array private function resizeWithImagick(string $src, string $dest, int $targetW, string $format, int $quality): bool { - // Fast path (#109): libvips when available; falls through to Imagick - // (below) when vips is absent or can't handle this format. + // Fast path (#109): libvips when available; on failure delegates to + // resizeWithImagickOnly() (the Imagick body) — vips is tried once here, + // never twice. if (\App\Services\Imaging\ImageEngine::encode($src, $dest, $targetW, $format, $quality, $this->envFlag('STRIP_EXIF', true))) { return true; } From 7184f4207ca170a139d9f7fec03f291b9e08cfe5 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 18 Jun 2026 01:04:39 +0200 Subject: [PATCH 10/11] test: reusable coverage for the #109 image-pipeline changes Adds 23 reusable, capability-guarded tests over the changed surface (skip cleanly where libvips/cjxl/GD are absent so CI stays portable): - ImageEngineTest (10): capabilities() contract + caching, jxl_write / heif_read backing-encoder consistency, encode() fast-path/fallback contract, JPEG-XL-via-cjxl encode (valid signature, no upscale, degenerate-input clamping), dimensions() vips/no-vips contract. - MediaControllerMediaTypeTest (9): JPEG/JPEG-XL served with correct MIME, ISO-BMFF + codestream JXL signatures accepted, spoofed / truncated / empty .jxl rejected by the strict magic-byte gate, unknown extension never resolves, public jxl cacheable, protected jxl quarantined + no-store. - ProtectedMediaStorageTest (+4): .jxl basename resolves and quarantines for protected albums / stays public for public ones, confinedUnlink removes jxl inside the media roots, and traversal dbPaths never resolve or delete. Together with this branch's MediaAccessSecurityTest (5), ImageEngineJxl Test (1) and the MigrationsTest jxl coverage, the changed code is locked in against regression. --- tests/Services/ImageEngineTest.php | 180 +++++++++++++++ .../Services/MediaControllerMediaTypeTest.php | 216 ++++++++++++++++++ tests/Services/ProtectedMediaStorageTest.php | 73 ++++++ 3 files changed, 469 insertions(+) create mode 100644 tests/Services/ImageEngineTest.php create mode 100644 tests/Services/MediaControllerMediaTypeTest.php diff --git a/tests/Services/ImageEngineTest.php b/tests/Services/ImageEngineTest.php new file mode 100644 index 00000000..2e4b8efa --- /dev/null +++ b/tests/Services/ImageEngineTest.php @@ -0,0 +1,180 @@ +tmp as $f) { + @unlink($f); + } + $this->tmp = []; + } + + // ── capabilities() contract ─────────────────────────────────────────── + + public function testCapabilitiesExposesEveryExpectedKey(): void + { + $caps = ImageEngine::capabilities(); + foreach (['vips', 'imagick', 'gd', 'heif_read', 'avif_write', 'jxl_write', 'opt_jpegoptim'] as $key) { + self::assertArrayHasKey($key, $caps, "capabilities() must expose '$key'"); + self::assertIsBool($caps[$key], "capabilities()['$key'] must be a bool"); + } + } + + public function testCapabilitiesAreCachedAcrossCalls(): void + { + // Same content on repeat (per-process memoization) — cheap and stable. + self::assertSame(ImageEngine::capabilities(), ImageEngine::capabilities()); + } + + public function testJxlWriteImpliesAnAvailableEncoder(): void + { + $caps = ImageEngine::capabilities(); + if (!$caps['jxl_write']) { + self::assertFalse($caps['jxl_write']); // nothing to prove on a no-jxl host + return; + } + // jxl_write is true ⇒ libvips can write jxl, OR Imagick+cjxl are both present. + $viaVips = $caps['vips']; + $viaCjxl = $caps['imagick'] && $this->onPath('cjxl'); + self::assertTrue($viaVips || $viaCjxl, 'jxl_write=true must be backed by vips or Imagick+cjxl'); + } + + public function testHeifReadImpliesVipsOrImagick(): void + { + $caps = ImageEngine::capabilities(); + if ($caps['heif_read']) { + self::assertTrue($caps['vips'] || $caps['imagick'], 'heif_read=true must be backed by vips or Imagick'); + } else { + self::assertFalse($caps['heif_read']); + } + } + + // ── encode() contract ───────────────────────────────────────────────── + + public function testEncodeReturnsFalseForJpegWhenVipsUnavailable(): void + { + if (ImageEngine::capabilities()['vips']) { + self::markTestSkipped('vips present — the no-vips fallback contract is not exercised here.'); + } + $dest = $this->tmpPath('.jpg'); + // No libvips → encode() must report false for jpeg/webp/avif so the + // caller falls back to its own Imagick/GD path (no file written). + self::assertFalse(ImageEngine::encode($this->makePng(), $dest, 32, 'jpeg', 80)); + self::assertFileDoesNotExist($dest); + } + + public function testEncodeProducesValidJxlWhenSupported(): void + { + if (!ImageEngine::capabilities()['jxl_write']) { + self::markTestSkipped('No JPEG-XL encoder (libvips+libjxl or cjxl) on this host.'); + } + $dest = $this->tmpPath('.jxl'); + self::assertTrue(ImageEngine::encode($this->makePng(128, 96), $dest, 64, 'jxl', 80)); + self::assertTrue($this->isJxl($dest), 'output must carry a JPEG-XL signature'); + } + + public function testEncodedJxlNeverUpscalesBeyondSource(): void + { + if (!ImageEngine::capabilities()['jxl_write']) { + self::markTestSkipped('No JPEG-XL encoder on this host.'); + } + // Source is 40px wide; asking for 1000 must NOT upscale. We can't read + // jxl dims with getimagesize, so assert via a re-encode round-trip is + // out of scope — instead assert the encode succeeds and the file is a + // valid, non-empty jxl (the 'size'=>'down' guard is what prevents the + // upscale; this proves the small-source path doesn't error). + $dest = $this->tmpPath('.jxl'); + self::assertTrue(ImageEngine::encode($this->makePng(40, 30), $dest, 1000, 'jxl', 70)); + self::assertGreaterThan(0, (int) filesize($dest)); + self::assertTrue($this->isJxl($dest)); + } + + public function testEncodeClampsDegenerateQualityAndWidth(): void + { + if (!ImageEngine::capabilities()['jxl_write']) { + self::markTestSkipped('No JPEG-XL encoder on this host.'); + } + // Degenerate inputs (0 width, out-of-range quality) must be clamped, not + // crash, and still yield a valid file. + $dest = $this->tmpPath('.jxl'); + self::assertTrue(ImageEngine::encode($this->makePng(64, 64), $dest, 0, 'jxl', 100000)); + self::assertTrue($this->isJxl($dest)); + } + + // ── dimensions() contract ───────────────────────────────────────────── + + public function testDimensionsReturnsNullWhenVipsUnavailable(): void + { + if (ImageEngine::capabilities()['vips']) { + self::markTestSkipped('vips present — the null fallback contract is not exercised here.'); + } + // No libvips → dimensions() returns null so callers fall back to their + // own Imagick ping / getimagesize path. + self::assertNull(ImageEngine::dimensions($this->makePng(20, 10))); + } + + public function testDimensionsReadsSizeWhenVipsAvailable(): void + { + if (!ImageEngine::capabilities()['vips']) { + self::markTestSkipped('vips absent — header dimension read not exercised here.'); + } + $dims = ImageEngine::dimensions($this->makePng(48, 24)); + self::assertSame([48, 24], $dims); + } + + // ── helpers ─────────────────────────────────────────────────────────── + + private function makePng(int $w = 64, int $h = 64): string + { + if (!\function_exists('imagecreatetruecolor') || !\function_exists('imagepng')) { + self::markTestSkipped('GD is required to synthesize the source image.'); + } + $path = $this->tmpPath('.png'); + $im = imagecreatetruecolor($w, $h); + imagefilledrectangle($im, 0, 0, $w - 1, $h - 1, imagecolorallocate($im, 180, 40, 90)); + imagepng($im, $path); + imagedestroy($im); + return $path; + } + + private function isJxl(string $path): bool + { + $head = (string) file_get_contents($path, false, null, 0, 12); + return str_starts_with($head, "\xFF\x0A") + || $head === "\x00\x00\x00\x0C\x4A\x58\x4C\x20\x0D\x0A\x87\x0A"; + } + + private function onPath(string $bin): bool + { + foreach (explode(PATH_SEPARATOR, (string) getenv('PATH')) as $dir) { + if ($dir !== '' && is_file("$dir/$bin") && is_executable("$dir/$bin")) { + return true; + } + } + return false; + } + + private function tmpPath(string $ext): string + { + $p = tempnam(sys_get_temp_dir(), 'cimaise_iet_'); + @unlink($p); + $p .= $ext; + $this->tmp[] = $p; + return $p; + } +} diff --git a/tests/Services/MediaControllerMediaTypeTest.php b/tests/Services/MediaControllerMediaTypeTest.php new file mode 100644 index 00000000..50ac8972 --- /dev/null +++ b/tests/Services/MediaControllerMediaTypeTest.php @@ -0,0 +1,216 @@ +nextId = random_int(6_100_000, 6_900_000); + $this->dbFile = sys_get_temp_dir() . '/cimaise_mediatype_' . uniqid('', true) . '.sqlite'; + $this->db = new Database(null, null, $this->dbFile, null, null, 'utf8mb4', 'utf8mb4_unicode_ci', true); + $this->db->pdo()->exec( + 'CREATE TABLE albums (id INTEGER PRIMARY KEY, slug TEXT, is_published INTEGER NOT NULL DEFAULT 1, + is_nsfw INTEGER NOT NULL DEFAULT 0, password_hash TEXT, allow_downloads INTEGER NOT NULL DEFAULT 1); + CREATE TABLE images (id INTEGER PRIMARY KEY, album_id INTEGER NOT NULL, original_path TEXT, mime TEXT); + CREATE TABLE image_variants (id INTEGER PRIMARY KEY AUTOINCREMENT, image_id INTEGER NOT NULL, + variant TEXT NOT NULL, format TEXT NOT NULL, path TEXT NOT NULL, width INTEGER, height INTEGER, size_bytes INTEGER);' + ); + } + + protected function tearDown(): void + { + $_SESSION = []; + unset($this->db); + foreach (array_merge($this->createdFiles, [$this->dbFile, $this->dbFile . '-wal', $this->dbFile . '-shm']) as $f) { + // nosemgrep: test-fixture cleanup; $f is a path this test created. + @unlink($f); // nosemgrep + } + } + + public function testJpegVariantServedAsImageJpeg(): void + { + $id = $this->insertImage(); + $this->writeJpegVariant($id, 'sm'); + $r = $this->servePublic($id, 'sm', 'jpg'); + self::assertSame(200, $r->getStatusCode()); + self::assertSame('image/jpeg', $r->getHeaderLine('Content-Type')); + } + + public function testJxlVariantServedAsImageJxl(): void + { + $id = $this->insertImage(); + $bytes = $this->writeRawVariant($id, 'md', 'jxl', self::JXL_CODESTREAM); + $r = $this->servePublic($id, 'md', 'jxl'); + self::assertSame(200, $r->getStatusCode(), 'a real .jxl must serve, not 404'); + self::assertSame('image/jxl', $r->getHeaderLine('Content-Type')); + self::assertSame($bytes, (string) $r->getBody()); + } + + public function testJxlContainerSignatureIsAccepted(): void + { + $id = $this->insertImage(); + $this->writeRawVariant($id, 'md', 'jxl', self::JXL_CONTAINER); + $r = $this->servePublic($id, 'md', 'jxl'); + self::assertSame(200, $r->getStatusCode(), 'ISO-BMFF "JXL " box must be accepted too'); + self::assertSame('image/jxl', $r->getHeaderLine('Content-Type')); + } + + public function testSpoofedJxlBytesRejected(): void + { + // .jxl extension but JPEG magic bytes → strict gate must reject (the + // extension is never trusted on DB-sourced paths). + $id = $this->insertImage(); + $this->writeRawVariant($id, 'md', 'jxl', "\xFF\xD8\xFF\xE0" . str_repeat("\x00", 32)); + self::assertSame(403, $this->servePublic($id, 'md', 'jxl')->getStatusCode()); + } + + public function testTruncatedJxlBytesRejected(): void + { + // A 1-byte file can't carry either JXL signature → reject, never 200. + $id = $this->insertImage(); + $this->writeRawVariant($id, 'md', 'jxl', "\xFF"); + self::assertSame(403, $this->servePublic($id, 'md', 'jxl')->getStatusCode()); + } + + public function testEmptyJxlFileRejected(): void + { + $id = $this->insertImage(); + $this->writeRawVariant($id, 'md', 'jxl', ''); + self::assertGreaterThanOrEqual(400, $this->servePublic($id, 'md', 'jxl')->getStatusCode()); + } + + public function testUnknownExtensionDoesNotResolve(): void + { + // The public /media/{path} filename whitelist is jpg|webp|avif|jxl|png; + // a .gif (or anything else) must 404, never stream. + $id = $this->insertImage(); + $r = $this->controller()->servePublic( + (new ServerRequestFactory())->createServerRequest('GET', "/media/{$id}_sm.gif"), + new Response(), + ['path' => "{$id}_sm.gif"] + ); + self::assertGreaterThanOrEqual(400, $r->getStatusCode()); + } + + public function testPublicJxlVariantIsCacheable(): void + { + $id = $this->insertImage(); + $this->writeRawVariant($id, 'md', 'jxl', self::JXL_CODESTREAM); + $r = $this->servePublic($id, 'md', 'jxl'); + self::assertSame(200, $r->getStatusCode()); + self::assertStringStartsWith('public,', $r->getHeaderLine('Cache-Control'), 'public-album jxl is cacheable'); + } + + public function testProtectedJxlVariantQuarantinedAndNeverCacheable(): void + { + $id = $this->insertImage(nsfw: true); + $this->writeRawVariant($id, 'md', 'jxl', self::JXL_CODESTREAM); + $_SESSION['nsfw_confirmed_global'] = true; + $r = $this->servePublic($id, 'md', 'jxl'); + self::assertSame(200, $r->getStatusCode()); + self::assertSame('image/jxl', $r->getHeaderLine('Content-Type')); + self::assertStringContainsString('no-store', $r->getHeaderLine('Cache-Control')); + self::assertFileExists($this->privatePath("{$id}_md.jxl"), 'protected jxl must be quarantined to protected-media'); + } + + // ── helpers ─────────────────────────────────────────────────────────── + + private function servePublic(int $id, string $variant, string $format) + { + return $this->controller()->servePublic( + (new ServerRequestFactory())->createServerRequest('GET', "/media/{$id}_{$variant}.{$format}"), + new Response(), + ['path' => "{$id}_{$variant}.{$format}"] + ); + } + + private function insertImage(int $albumId = 1, bool $nsfw = false): int + { + $id = $this->nextId++; + $this->db->pdo()->prepare( + 'INSERT OR IGNORE INTO albums (id, slug, is_published, is_nsfw, password_hash, allow_downloads) VALUES (?,?,1,?,NULL,1)' + )->execute([$albumId, 'album-' . $albumId, $nsfw ? 1 : 0]); + $this->db->pdo()->prepare('INSERT INTO images (id, album_id, original_path, mime) VALUES (?,?,?,?)') + ->execute([$id, $albumId, '/storage/originals/x.jpg', 'image/jpeg']); + return $id; + } + + private function writeRawVariant(int $id, string $variant, string $format, string $bytes): string + { + $dbPath = "/media/{$id}_{$variant}.{$format}"; + $path = $this->publicPath(basename($dbPath)); + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0775, true); + } + file_put_contents($path, $bytes); + $this->createdFiles[] = $path; + $this->db->pdo()->prepare( + 'INSERT INTO image_variants (image_id, variant, format, path, width, height, size_bytes) VALUES (?,?,?,?,16,16,?)' + )->execute([$id, $variant, $format, $dbPath, strlen($bytes)]); + return $bytes; + } + + private function writeJpegVariant(int $id, string $variant): void + { + if (!\function_exists('imagecreatetruecolor') || !\function_exists('imagejpeg')) { + self::markTestSkipped('GD JPEG support required.'); + } + $path = $this->publicPath("{$id}_{$variant}.jpg"); + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0775, true); + } + $im = imagecreatetruecolor(16, 16); + imagefilledrectangle($im, 0, 0, 15, 15, imagecolorallocate($im, 10, 20, 30)); + imagejpeg($im, $path, 85); + imagedestroy($im); + $this->createdFiles[] = $path; + $this->db->pdo()->prepare( + 'INSERT INTO image_variants (image_id, variant, format, path, width, height, size_bytes) VALUES (?,?,?,?,16,16,?)' + )->execute([$id, $variant, 'jpg', "/media/{$id}_{$variant}.jpg", filesize($path)]); + } + + private function controller(): MediaController + { + return new MediaController($this->db, new UploadService($this->db)); + } + + private function publicPath(string $basename): string + { + return dirname(__DIR__, 2) . '/public/media/' . $basename; + } + + private function privatePath(string $basename): string + { + $p = dirname(__DIR__, 2) . '/storage/protected-media/' . $basename; + $this->createdFiles[] = $p; + return $p; + } +} diff --git a/tests/Services/ProtectedMediaStorageTest.php b/tests/Services/ProtectedMediaStorageTest.php index dd05a6eb..832cbe53 100644 --- a/tests/Services/ProtectedMediaStorageTest.php +++ b/tests/Services/ProtectedMediaStorageTest.php @@ -170,6 +170,79 @@ public function testSharedVariantStaysPublicWhileReferencedByPublicAlbum(): void self::assertFileDoesNotExist($this->privatePath(basename($dbPath))); } + public function testJxlVariantIsQuarantinedForProtectedAlbum(): void + { + // mediaBasename() must accept .jxl so protected jxl variants relocate + // like jpg/webp/avif (#109 serving completion). + $imageId = $this->insertImage(nsfw: true); + $dbPath = "/media/{$imageId}_md.jxl"; + $publicPath = $this->writeRaw($this->publicPath("{$imageId}_md.jxl"), "\xFF\x0A\x00\x00"); + $this->db->pdo()->prepare( + 'INSERT INTO image_variants (image_id, variant, format, path) VALUES (?, ?, ?, ?)' + )->execute([$imageId, 'md', 'jxl', $dbPath]); + + $resolved = (new ProtectedMediaStorage($this->db))->resolveVariantPath($dbPath, true); + + self::assertNotNull($resolved, 'a .jxl dbPath must resolve (mediaBasename accepts jxl)'); + self::assertFileExists($this->privatePath("{$imageId}_md.jxl"), 'protected jxl quarantined to protected-media'); + self::assertFileDoesNotExist($publicPath, 'protected jxl removed from public/media'); + } + + public function testJxlVariantStaysPublicForPublicAlbum(): void + { + $imageId = $this->insertImage(); // public album + $dbPath = "/media/{$imageId}_md.jxl"; + $publicPath = $this->writeRaw($this->publicPath("{$imageId}_md.jxl"), "\xFF\x0A\x00\x00"); + $this->db->pdo()->prepare( + 'INSERT INTO image_variants (image_id, variant, format, path) VALUES (?, ?, ?, ?)' + )->execute([$imageId, 'md', 'jxl', $dbPath]); + + $resolved = (new ProtectedMediaStorage($this->db))->resolveVariantPath($dbPath, false); + + self::assertNotNull($resolved); + self::assertFileExists($publicPath, 'public jxl stays in public/media'); + self::assertFileDoesNotExist($this->privatePath("{$imageId}_md.jxl")); + } + + public function testDeleteVariantCopiesRemovesJxlInsideMediaRoots(): void + { + // confinedUnlink positive path: a jxl copy in public + private is + // removed once the last DB reference is gone. + $imageId = $this->insertImage(); + $dbPath = "/media/{$imageId}_md.jxl"; + $pub = $this->writeRaw($this->publicPath("{$imageId}_md.jxl"), "\xFF\x0A\x00\x00"); + $priv = $this->writeRaw($this->privatePath("{$imageId}_md.jxl"), "\xFF\x0A\x00\x00"); + // No image_variants row references $dbPath → deleteVariantCopies removes the bytes. + + $ok = (new ProtectedMediaStorage($this->db))->deleteVariantCopies($dbPath); + + self::assertTrue($ok); + self::assertFileDoesNotExist($pub); + self::assertFileDoesNotExist($priv); + } + + public function testTraversalDbPathNeverResolvesOrDeletes(): void + { + // mediaBasename() rejects any path containing '..' or outside /media/, + // so confinedUnlink/resolveVariantPath can never escape the media dirs. + $storage = new ProtectedMediaStorage($this->db); + self::assertNull($storage->resolveVariantPath('/media/../../etc/passwd', true)); + self::assertNull($storage->resolveVariantPath('/etc/passwd', false)); + // deleteVariantCopies on a traversal path is a no-op that returns false + // (no basename ⇒ nothing deleted), and obviously touches nothing outside. + self::assertFalse($storage->deleteVariantCopies('/media/../../../etc/hosts')); + } + + private function writeRaw(string $path, string $bytes): string + { + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0775, true); + } + file_put_contents($path, $bytes); + $this->createdFiles[] = $path; + return $path; + } + private function insertImage( int $albumId = 1, bool $nsfw = false, From a062654c89525daba2c2d87b56eb6d345b9df3bb Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 18 Jun 2026 07:01:30 +0200 Subject: [PATCH 11/11] docs(readme): document the modern image pipeline (#109) Cover the capability-detected engine (libvips fast path + Imagick/GD fallback), HEIC/HEIF import for iPhone photos, and opt-in JPEG-XL generation+serving (libvips+libjxl or cjxl): variant tree, quality table, format/HEIC settings, and the headline feature line. --- README.md | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e8b6500c..7a4d87bb 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ You've spent hours in the darkroom, days on location, years perfecting your craf ### Why Photographers Choose Cimaise -**Blazing Fast** — Your images load instantly with automatic AVIF, WebP, and JPEG optimization. Six responsive breakpoints ensure perfect delivery on any device. No more visitors leaving because your site is slow. +**Blazing Fast** — Your images load instantly with automatic AVIF, WebP, and JPEG optimization (plus optional JPEG-XL), powered by a libvips engine that also imports iPhone HEIC photos. Six responsive breakpoints ensure perfect delivery on any device. No more visitors leaving because your site is slow. **Film-Ready** — Unlike generic CMSs, Cimaise speaks your language. Track cameras, lenses, film stocks, developers, and labs. Whether you shoot Portra 400 on a Hasselblad or digital on a Leica, your metadata is organized and searchable. @@ -634,21 +634,34 @@ Navigation menus show accurate album counts per category. Protected albums (NSFW **Upload once. Cimaise handles everything.** +Variants are produced by a capability-detected engine: a fast, low-memory +**libvips** path (shrink-on-load) when available, with automatic fallback to +**Imagick**/GD so it runs unchanged on any host. **HEIC/HEIF uploads** (iPhone +photos) are accepted whenever the server can decode them (libheif via libvips, +or the Imagick HEIC delegate) and converted to standard web variants. + Every photo you upload automatically generates optimized variants: ```text -Your Upload (8000x5333 RAW/JPEG) +Your Upload (8000x5333 RAW / JPEG / PNG / WebP / HEIC) ↓ Originals stored safely in storage/originals/ ↓ Public variants generated: - ├── Small (768px) → AVIF, WebP, JPEG - ├── Medium (1200px) → AVIF, WebP, JPEG - ├── Large (1920px) → AVIF, WebP, JPEG - ├── XL (2560px) → AVIF, WebP, JPEG - └── XXL (3840px) → AVIF, WebP, JPEG + ├── Small (768px) → AVIF, WebP, JPEG (+ JPEG-XL, opt-in) + ├── Medium (1200px) → AVIF, WebP, JPEG (+ JPEG-XL, opt-in) + ├── Large (1920px) → AVIF, WebP, JPEG (+ JPEG-XL, opt-in) + ├── XL (2560px) → AVIF, WebP, JPEG (+ JPEG-XL, opt-in) + └── XXL (3840px) → AVIF, WebP, JPEG (+ JPEG-XL, opt-in) ``` +**JPEG-XL (opt-in)** — when enabled in *Settings → Image Processing* and the +server can encode it (a libvips build with libjxl, or the standalone `cjxl` +binary), Cimaise also emits `.jxl` variants and serves them via `` to +browsers that support them, falling back to AVIF/WebP/JPEG everywhere else. Off +by default; check *Admin → Diagnostics → Imaging Engine* to see what your host +supports. + ### Client-Side Compression Before upload, Cimaise compresses images in your browser: @@ -675,6 +688,7 @@ From Admin → Settings → Image Processing: | AVIF | 50% | 40-70% | | WebP | 75% | 60-90% | | JPEG | 85% | 70-95% | +| JPEG-XL (opt-in) | 60% | 50-90% | --- @@ -695,7 +709,8 @@ Cimaise focuses on what photographers actually need: - **Home Page Layout** — 12 templates, switchable anytime ### Image Handling -- **Format Enable/Disable** — Turn off AVIF if your host doesn't support it +- **Format Enable/Disable** — Turn off AVIF if your host doesn't support it, or turn on JPEG-XL where the server can encode it +- **HEIC/HEIF Import** — Accept iPhone photos directly when the server can decode them (libheif/Imagick); originals keep their extension, variants are standard web formats - **Quality Sliders** — Balance quality vs file size per format - **Breakpoints** — Customize which sizes get generated - **Lazy Loading** — Above-fold images load instantly, below-fold on scroll