20, 'webVersion' => true, 'webMaxSide' => 2000, 'webQuality' => 82]; } /** Sanitised media settings: defaults, overlaid by what the site stored. */ function raijuMediaSettings(): array { static $cache = null; if ($cache !== null) return $cache; $stored = []; if (defined('SETTINGS_PATH') && is_file(SETTINGS_PATH)) { $data = json_decode((string)file_get_contents(SETTINGS_PATH), true); if (is_array($data) && isset($data['media']) && is_array($data['media'])) $stored = $data['media']; } return $cache = raijuSanitizeMediaSettings($stored); } /** Clamp a media settings array to the allowed values; unknown keys are dropped. */ function raijuSanitizeMediaSettings(array $input): array { $d = raijuMediaSettingsDefaults(); $int = static function ($value, int $default, int $min, int $max): int { return is_numeric($value) ? max($min, min($max, (int)$value)) : $default; }; return [ 'maxUploadMb' => $int($input['maxUploadMb'] ?? null, $d['maxUploadMb'], 1, 200), 'webVersion' => array_key_exists('webVersion', $input) ? !in_array($input['webVersion'], [false, 0, '0', 'false', '', null], true) : $d['webVersion'], 'webMaxSide' => $int($input['webMaxSide'] ?? null, $d['webMaxSide'], 800, 6000), 'webQuality' => $int($input['webQuality'] ?? null, $d['webQuality'], 50, 95), ]; } /** The image upload limit Raiju enforces, in bytes. */ function raijuMediaImageMaxBytes(): int { return raijuMediaSettings()['maxUploadMb'] * 1024 * 1024; } /** Parse a php.ini size such as "8M" or "512K" into bytes; -1 or 0 mean unlimited. */ function raijuIniBytes($value): int { $value = trim((string)$value); if ($value === '' || $value === '-1' || $value === '0') return PHP_INT_MAX; $unit = strtolower(substr($value, -1)); $number = (float)$value; return (int)($number * match ($unit) { 'g' => 1073741824, 'm' => 1048576, 'k' => 1024, default => 1 }); } /** The largest file PHP lets through on this server, in bytes (the smaller of the two ini limits). */ function raijuUploadServerLimit(): int { return min(raijuIniBytes(ini_get('upload_max_filesize')), raijuIniBytes(ini_get('post_max_size'))); } /** Whole megabytes for messages: "2 MB", "20 MB", "0.5 MB" for odd values. */ function raijuMegabytes(int $bytes): string { $mb = $bytes / 1048576; return ($mb >= 1 && abs($mb - round($mb)) < 0.05) ? (string)round($mb) : rtrim(rtrim(number_format($mb, 1, '.', ''), '0'), '.'); } /** Everything the library needs to show and check limits. */ function raijuMediaUploadLimits(): array { $server = raijuUploadServerLimit(); $image = raijuMediaImageMaxBytes(); $s = raijuMediaSettings(); return [ 'serverMaxBytes' => $server === PHP_INT_MAX ? null : $server, 'imageMaxBytes' => $image, 'imageEffectiveBytes' => min($image, $server), 'webVersion' => $s['webVersion'] && raijuMediaCanEncodeWebp(), 'webMaxSide' => $s['webMaxSide'], 'webQuality' => $s['webQuality'], ]; } function raijuMediaCanEncodeWebp(): bool { return function_exists('imagecreatefromstring') && function_exists('imagewebp'); } /** Folder for the uploaded originals, a sibling of the images folder. */ function raijuMediaOriginalsPath(bool $trash = false): string { $images = defined('IMAGES_PATH') ? IMAGES_PATH : dirname(__DIR__) . '/assets/images/'; return rtrim(dirname(rtrim($images, '/')), '/') . ($trash ? '/images-originals-trash/' : '/images-originals/'); } /** Public path (relative to admin/) of the originals folder. */ function raijuMediaOriginalsPublicPath(): string { return '../assets/images-originals/'; } /** * Whether an image of this size should get a web version, and whether the * server can make one. Returns a reason string when it stays as uploaded. */ function raijuMediaWebVersionDecision(string $file, string $mime, int $bytes, array $limits): ?string { if (!$limits['webVersion']) return 'off'; if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) return 'format'; $info = @getimagesize($file); if ($info === false) return 'unreadable'; [$w, $h] = [(int)$info[0], (int)$info[1]]; if ($w < 1 || $h < 1 || $w > RAIJU_MEDIA_MAX_INPUT_SIDE || $h > RAIJU_MEDIA_MAX_INPUT_SIDE || $w * $h > RAIJU_MEDIA_MAX_INPUT_PIXELS) return 'pixels'; if (max($w, $h) <= $limits['webMaxSide'] && $bytes <= RAIJU_MEDIA_WEB_MIN_BYTES) return 'small'; if (!raijuMediaImageFitsMemory($w, $h)) return 'memory'; return null; } /** GD needs about 4 bytes per pixel, twice while scaling; raise the limit once when the server allows it. */ function raijuMediaImageFitsMemory(int $width, int $height): bool { $needed = $width * $height * 9 + 32 * 1048576; $fits = static function () use ($needed): bool { $limit = ini_get('memory_limit'); if ($limit === false || $limit === '' || $limit === '-1') return true; return $needed < (raijuIniBytes($limit) - memory_get_usage()) * 0.9; }; if ($fits()) return true; $wanted = (int)ceil(($needed + memory_get_usage()) / 1048576 / 64) * 64; @ini_set('memory_limit', max($wanted, 256) . 'M'); return $fits(); } /** EXIF orientation of a JPEG (1 when none); a copy of the agent's reader so the library has no agent dependency. */ function raijuMediaJpegOrientation(string $binary): int { if (strncmp($binary, "\xFF\xD8", 2) !== 0) return 1; if (function_exists('exif_read_data')) { $exif = @exif_read_data('data://image/jpeg;base64,' . base64_encode(substr($binary, 0, 262144))); if (is_array($exif) && isset($exif['Orientation'])) return max(1, min(8, (int)$exif['Orientation'])); } return 1; } /** * Write the web version of $sourceFile as $targetFile (WebP). Returns * [width, height, bytes] or null when encoding failed; the caller keeps the * upload as it is in that case. */ function raijuMediaWriteWebVersion(string $sourceFile, string $targetFile, string $mime, array $limits): ?array { $binary = (string)file_get_contents($sourceFile); if ($binary === '') return null; $image = @imagecreatefromstring($binary); if (!$image instanceof GdImage) return null; try { if ($mime === 'image/jpeg') { $orientation = raijuMediaJpegOrientation($binary); $rotate = [3 => 180, 5 => -90, 6 => -90, 7 => 90, 8 => 90][$orientation] ?? 0; if ($rotate !== 0) { $rotated = imagerotate($image, $rotate, 0); if ($rotated instanceof GdImage) $image = $rotated; } if (in_array($orientation, [2, 5, 7], true)) imageflip($image, IMG_FLIP_HORIZONTAL); if ($orientation === 4) imageflip($image, IMG_FLIP_VERTICAL); } $width = imagesx($image); $height = imagesy($image); $longest = max($width, $height); $maxSide = (int)$limits['webMaxSide']; if ($longest > $maxSide) { // Default interpolation: some GD builds refuse IMG_BICUBIC and return false $scaled = imagescale($image, (int)max(1, round($width * $maxSide / $longest)), (int)max(1, round($height * $maxSide / $longest))); if ($scaled instanceof GdImage) $image = $scaled; $width = imagesx($image); $height = imagesy($image); } imagealphablending($image, false); imagesavealpha($image, true); ob_start(); $ok = imagewebp($image, null, (int)$limits['webQuality']); $out = (string)ob_get_clean(); } finally { unset($image); } if (!$ok || $out === '') return null; if (file_put_contents($targetFile, $out, LOCK_EX) === false) return null; return ['width' => $width, 'height' => $height, 'bytes' => strlen($out)]; } /** The original's relative name for a web file: same folder and base name, the uploaded extension. */ function raijuMediaOriginalName(string $webRelative, string $extension): string { $folder = trim(str_replace('\\', '/', dirname($webRelative)), '.'); return ($folder !== '' ? $folder . '/' : '') . pathinfo($webRelative, PATHINFO_FILENAME) . '.' . $extension; } /** Find the stored original for a library image, if any. */ function raijuMediaOriginalFor(string $webRelative, bool $trash = false): ?array { $dir = raijuMediaOriginalsPath($trash); $folder = trim(str_replace('\\', '/', dirname($webRelative)), '.'); $stem = pathinfo($webRelative, PATHINFO_FILENAME); foreach (['jpg', 'jpeg', 'png', 'webp'] as $ext) { $relative = ($folder !== '' ? $folder . '/' : '') . $stem . '.' . $ext; $file = $dir . $relative; if (is_file($file) && !is_link($file)) { $bytes = (int)filesize($file); $info = @getimagesize($file); return [ 'name' => $relative, 'path' => $trash ? '' : raijuMediaOriginalsPublicPath() . $relative, 'sizeBytes' => $bytes, 'size' => function_exists('formatAssetSize') ? formatAssetSize($bytes) : raijuMegabytes($bytes) . ' MB', 'width' => $info ? (int)$info[0] : 0, 'height' => $info ? (int)$info[1] : 0, ]; } } return null; } /** Move an original along with its web file (rename, move, trash, restore). */ function raijuMediaOriginalRelocate(string $fromRelative, bool $fromTrash, string $toRelative, bool $toTrash): void { $original = raijuMediaOriginalFor($fromRelative, $fromTrash); if ($original === null) return; $ext = pathinfo($original['name'], PATHINFO_EXTENSION); $target = raijuMediaOriginalsPath($toTrash) . raijuMediaOriginalName($toRelative, $ext); $dir = dirname($target); if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return; @rename(raijuMediaOriginalsPath($fromTrash) . $original['name'], $target); } /** Delete the original of a web file that is being removed for good. */ function raijuMediaOriginalForget(string $webRelative, bool $trash): void { $original = raijuMediaOriginalFor($webRelative, $trash); if ($original !== null) @unlink(raijuMediaOriginalsPath($trash) . $original['name']); } /** * Store an uploaded image: as a web version with the original kept, or as * it is. $moveFile moves the upload into place (move_uploaded_file in the * API, rename in tests). Returns the final relative name and what happened. * * @return array{name:string,webVersion:?array,reason:?string}|null null when nothing could be stored */ function raijuMediaStoreImage(string $tmpFile, string $directory, string $relativeStem, string $extension, string $mime, callable $moveFile): ?array { $limits = raijuMediaUploadLimits(); $bytes = (int)@filesize($tmpFile); $reason = raijuMediaWebVersionDecision($tmpFile, $mime, $bytes, $limits); $plainName = $relativeStem . '.' . $extension; if ($reason !== null) { if (!$moveFile($tmpFile, $directory . $plainName)) return null; return ['name' => $plainName, 'webVersion' => null, 'reason' => $reason]; } $webName = $relativeStem . '.webp'; $written = raijuMediaWriteWebVersion($tmpFile, $directory . $webName, $mime, $limits); if ($written === null) { @unlink($directory . $webName); if (!$moveFile($tmpFile, $directory . $plainName)) return null; return ['name' => $plainName, 'webVersion' => null, 'reason' => 'encode']; } $originalFile = raijuMediaOriginalsPath() . raijuMediaOriginalName($webName, $extension); $originalDir = dirname($originalFile); if ((!is_dir($originalDir) && !@mkdir($originalDir, 0755, true)) || !$moveFile($tmpFile, $originalFile)) { // The web version exists; losing the original is the lesser harm, but say so. return ['name' => $webName, 'webVersion' => $written + ['originalKept' => false, 'originalBytes' => $bytes], 'reason' => null]; } return ['name' => $webName, 'webVersion' => $written + ['originalKept' => true, 'originalBytes' => $bytes], 'reason' => null]; }