278 lines
13 KiB
PHP
278 lines
13 KiB
PHP
<?php
|
||
/**
|
||
* Web versions for uploaded images.
|
||
*
|
||
* Large photographs may enter the library (the limit is a setting, 20 MB by
|
||
* default), but what pages embed should be small. So an upload of a JPEG,
|
||
* PNG or WebP that is bigger than the web needs is re-encoded once: scaled
|
||
* to the configured longest side and saved as WebP under the chosen name.
|
||
* The file as it was uploaded moves to assets/images-originals/ next to the
|
||
* library, keyed by the web file's relative name, so the original can be
|
||
* downloaded, travels along on rename, move, trash and restore, and goes
|
||
* with its web version when that is deleted for good.
|
||
*
|
||
* Settings (content/settings.json, "media"):
|
||
* maxUploadMb upload limit for images in MB (1–200, default 20)
|
||
* webVersion create web versions at all (default true)
|
||
* webMaxSide longest side of the web version in px (800–6000, default 2000)
|
||
* webQuality WebP quality (50–95, default 82)
|
||
*
|
||
* The PHP limits upload_max_filesize and post_max_size cap every upload
|
||
* before Raiju sees it; raijuUploadServerLimit() reads them so the
|
||
* dashboard can show the limit that really applies.
|
||
*/
|
||
|
||
const RAIJU_MEDIA_WEB_MIN_BYTES = 400 * 1024; // smaller files stay as they are
|
||
const RAIJU_MEDIA_LARGE_BYTES = 2 * 1024 * 1024; // above this a library image is flagged as large
|
||
const RAIJU_MEDIA_MAX_INPUT_SIDE = 12000;
|
||
const RAIJU_MEDIA_MAX_INPUT_PIXELS = 60000000;
|
||
|
||
function raijuMediaSettingsDefaults(): array {
|
||
return ['maxUploadMb' => 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];
|
||
}
|