mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
fc758c701a
Both thumbnail routes treat "the file exists" as "the rendition is cached", and nothing ever invalidates one: RenderedImageCache::flush() runs on ImageRenderingChanged, which no core code raises. Whatever is at the path is what every later viewer gets. ThumbnailGenerator encoded straight onto that path. A render that died partway -- a full volume, a killed worker -- left a half-written file that was then served as the rendition for good, and two requests rendering the same file at once encoded into one path together. It now writes beside the destination and renames into place. rename() within a directory is atomic and replaces what is there, so the path is either the previous rendition or a complete new one, and the loser of a race leaves a whole image rather than a mixture of two. The temporary file is removed on the way out either way. The read side gets the other half: an empty file is not a rendition, so both routes replace one rather than serve it. Writing through a temporary file means this state can no longer be created here, but an installation that ran an older version can already have it on disk, and nothing else will ever clear it. Three tests: an empty rendition is replaced on the signed-in route and on the public one, and a successful render leaves nothing half-written behind. Without the fix the first two go red; the third is about the fix's own temporary file and passes either way.
165 lines
5.6 KiB
PHP
165 lines
5.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\Thumbnails;
|
|
|
|
use App\Modules\Files\Thumbnails\Events\RenderingImage;
|
|
use claviska\SimpleImage;
|
|
use Illuminate\Support\Facades\Event;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Wraps claviska/simpleimage — the same GD-backed library v1 uses — to
|
|
* produce a bounded rendition of an uploaded image. bestFit() (not
|
|
* thumbnail()) so a portrait or landscape original keeps its aspect ratio
|
|
* inside the box instead of being cropped to a square, and never scales
|
|
* up, so a small original is served at its own size.
|
|
*
|
|
* Named for the thumbnail it originally only made; it produces every
|
|
* ImageRendition now, previews included.
|
|
*/
|
|
class ThumbnailGenerator
|
|
{
|
|
/**
|
|
* Guards against decompression-bomb-style images: a huge pixel count
|
|
* can consume excessive memory/CPU to decode even from a small file
|
|
* on disk.
|
|
*/
|
|
private const MAX_SOURCE_MEGAPIXELS = 40;
|
|
|
|
/**
|
|
* Raster formats SimpleImage/GD can decode. SVGs are already
|
|
* vector/small, so the frontend renders the original file directly
|
|
* instead of asking for a thumbnail.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
public const SUPPORTED_MIME_TYPES = [
|
|
'image/jpeg',
|
|
'image/png',
|
|
'image/gif',
|
|
'image/webp',
|
|
];
|
|
|
|
public static function supports(string $mimeType): bool
|
|
{
|
|
return in_array($mimeType, self::SUPPORTED_MIME_TYPES, true);
|
|
}
|
|
|
|
/**
|
|
* The path one audience's cached copy of one rendition of a file
|
|
* lives (or would live) at on the local 'files' disk, or null when
|
|
* the mime type has no rendition at all — the single authoritative
|
|
* definition shared by whatever generates it (FileThumbnailController,
|
|
* PublicGroupsController) and whatever cleans it up (FileDiskCleanup).
|
|
*
|
|
* Keyed on both because a RenderingImage listener may draw them
|
|
* differently; see ImageAudience and ImageRendition.
|
|
*/
|
|
public static function pathFor(int $fileId, string $mimeType, ImageAudience $audience, ImageRendition $rendition): ?string
|
|
{
|
|
if (! self::supports($mimeType)) {
|
|
return null;
|
|
}
|
|
|
|
return $rendition->directory().'/'.$audience->pathPrefix().$fileId.'.'.self::extensionFor($mimeType);
|
|
}
|
|
|
|
/**
|
|
* Every cached rendition of one file, for every audience — what
|
|
* deleting the file has to remove. Derived from the two enums rather
|
|
* than spelled out, so adding a rendition or an audience cannot
|
|
* leave bytes behind.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
public static function pathsFor(int $fileId, string $mimeType): array
|
|
{
|
|
$paths = [];
|
|
|
|
foreach (ImageRendition::cases() as $rendition) {
|
|
foreach (ImageAudience::cases() as $audience) {
|
|
$path = self::pathFor($fileId, $mimeType, $audience, $rendition);
|
|
|
|
if ($path !== null) {
|
|
$paths[] = $path;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $paths;
|
|
}
|
|
|
|
private static function extensionFor(string $mimeType): string
|
|
{
|
|
return match ($mimeType) {
|
|
'image/jpeg' => 'jpg',
|
|
'image/png' => 'png',
|
|
'image/gif' => 'gif',
|
|
'image/webp' => 'webp',
|
|
default => 'bin',
|
|
};
|
|
}
|
|
|
|
public function generate(
|
|
string $sourcePath,
|
|
string $destinationPath,
|
|
string $mimeType,
|
|
ImageAudience $audience,
|
|
ImageRendition $rendition,
|
|
): void {
|
|
$dimensions = @getimagesize($sourcePath);
|
|
|
|
if ($dimensions === false) {
|
|
throw new RuntimeException('Could not read image dimensions.');
|
|
}
|
|
|
|
[$width, $height] = $dimensions;
|
|
|
|
if ($width * $height > self::MAX_SOURCE_MEGAPIXELS * 1_000_000) {
|
|
throw new RuntimeException('Image is too large to render.');
|
|
}
|
|
|
|
$bound = $rendition->maxDimension();
|
|
|
|
$image = new SimpleImage;
|
|
$image->fromFile($sourcePath)
|
|
->autoOrient()
|
|
->bestFit($bound, $bound);
|
|
|
|
// The seam packages hook to decorate a rendered image — a
|
|
// watermark, today. Dispatched before the encode so a listener's
|
|
// changes cost no extra round trip through the codec; with nothing
|
|
// listening the image is written exactly as produced above.
|
|
Event::dispatch(new RenderingImage($image, $mimeType, $audience, $rendition));
|
|
|
|
// Written beside the destination and renamed into place, so the
|
|
// cached path never exists half-finished. Both callers test only
|
|
// that the path exists and then serve whatever is there
|
|
// (FileThumbnailController::render, PublicGroupsController::
|
|
// thumbnail), and nothing ever invalidates a rendition —
|
|
// RenderedImageCache::flush() runs on an event no core code raises.
|
|
// A render that died partway would therefore be served as the
|
|
// rendition from then on.
|
|
//
|
|
// It also settles the race: two requests rendering the same file at
|
|
// once used to encode into one path together. rename() within a
|
|
// directory is atomic and replaces what is there, so now the loser
|
|
// leaves a complete rendition behind rather than a mixture of two.
|
|
$temporaryPath = $destinationPath.'.'.bin2hex(random_bytes(8)).'.partial';
|
|
|
|
try {
|
|
$image->toFile($temporaryPath, $mimeType);
|
|
|
|
if (! rename($temporaryPath, $destinationPath)) {
|
|
throw new RuntimeException('Could not move the rendered image into place.');
|
|
}
|
|
} finally {
|
|
if (is_file($temporaryPath)) {
|
|
@unlink($temporaryPath);
|
|
}
|
|
}
|
|
}
|
|
}
|