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 diff --git a/app/Controllers/Admin/DiagnosticsController.php b/app/Controllers/Admin/DiagnosticsController.php index 9eb665b3..90a9163e 100644 --- a/app/Controllers/Admin/DiagnosticsController.php +++ b/app/Controllers/Admin/DiagnosticsController.php @@ -221,6 +221,32 @@ 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)' + : ($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', + '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'], + ]))) ?: '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/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/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 new file mode 100644 index 00000000..e491af0d --- /dev/null +++ b/app/Services/Imaging/ImageEngine.php @@ -0,0 +1,359 @@ +|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'), + // 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). + 'opt_jpegoptim' => self::binaryExists('jpegoptim'), + ]; + + 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; 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' — 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). + */ + public static function encode( + string $src, + string $dest, + int $targetW, + string $format, + 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; + } + + 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'); + // 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; + } + } + + /** + * 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. + */ + private 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]); + } + // 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 ──────────────────────────────────────────────────────── + + /** + * @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. 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); + } + } catch (\Throwable) { + // ignore — never fail a variant over optimization + } + } +} 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/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..16a12834 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/jxl 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,14 @@ 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']) { + 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) $fileHeader = file_get_contents($filePath, false, null, 0, 12); if ($fileHeader === false) { @@ -118,6 +145,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 +163,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 +180,43 @@ 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]]; + } + // 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(); + $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 +265,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); @@ -208,11 +276,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; } } @@ -302,6 +371,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. @@ -336,6 +416,23 @@ 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; 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; + } + 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(); @@ -370,8 +467,15 @@ 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, $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); @@ -579,7 +683,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 c4b0d49b..0bda7d6b 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; @@ -123,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 = []; @@ -133,10 +140,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; @@ -149,35 +164,90 @@ 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; + // 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), + $stripExif + ); + + 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) { - $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); + $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(?,?,?,?,?,?,?)', + $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/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') }} +
diff --git a/app/Views/admin/settings.twig b/app/Views/admin/settings.twig index 257a37c7..3db086ea 100644 --- a/app/Views/admin/settings.twig +++ b/app/Views/admin/settings.twig @@ -42,9 +42,16 @@ JPEG - {{ trans('admin.settings.jpg_desc') }} + +
-
+
@@ -57,6 +64,10 @@
+
+ + +
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 %} 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/" 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 50631b0d..888702bd 100644 Binary files a/database/template.sqlite and b/database/template.sqlite differ 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/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/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 @@ +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/MediaAccessSecurityTest.php b/tests/Services/MediaAccessSecurityTest.php new file mode 100644 index 00000000..2695c254 --- /dev/null +++ b/tests/Services/MediaAccessSecurityTest.php @@ -0,0 +1,400 @@ +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'); + } + + // ── 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 + * 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; + } +} 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, 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",