fix: harden resize pipeline against concurrent reads and corrupt sources
- Write resized output to a sibling temp file and atomically rename into the cache path so concurrent requests never observe a half-written file (was surfacing as IDAT CRC / truncated-zlib errors on first load). - Attempt a lenient PNG re-encode via GD then Imagick's png:preserve-corrupt-image before Spatie, so pedantically-invalid-but- intact uploads still resize instead of 500ing. - Fall back to serving the original bytes on unrecoverable decode errors (typically truncated uploads) instead of throwing — matches what the browser already tolerates and avoids 500s on damaged sources. - Backfill extension via MIME sniff when the model lacks one, early-return for gif/svg, touch cache hits for LRU-style cleanup, and accept canvasX/canvasY/offsetX/offsetY for padded-canvas resizes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5a6985071c
commit
361e48762a
|
|
@ -318,6 +318,10 @@ class File extends Model
|
||||||
cached: $cached,
|
cached: $cached,
|
||||||
quality: $quality,
|
quality: $quality,
|
||||||
position: $position,
|
position: $position,
|
||||||
|
canvasX: $request->get('canvasX'),
|
||||||
|
canvasY: $request->get('canvasY'),
|
||||||
|
offsetX: (int) $request->get('x', 0),
|
||||||
|
offsetY: (int) $request->get('y', 0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,26 +333,44 @@ class File extends Model
|
||||||
bool $cached = true,
|
bool $cached = true,
|
||||||
?int $quality = null,
|
?int $quality = null,
|
||||||
string $position = 'cover',
|
string $position = 'cover',
|
||||||
|
string|int|null $canvasX = null,
|
||||||
|
string|int|null $canvasY = null,
|
||||||
|
int $offsetX = 0,
|
||||||
|
int $offsetY = 0,
|
||||||
): string {
|
): string {
|
||||||
$path = $this->path;
|
$path = $this->path;
|
||||||
$ext = strtolower($this->extension ?? pathinfo($path, PATHINFO_EXTENSION));
|
|
||||||
|
// Resolve / backfill extension via MIME sniff when missing on the model.
|
||||||
|
$ext = strtolower($this->extension ?? '');
|
||||||
|
if (! $ext && file_exists($path)) {
|
||||||
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$ext = explode('/', $finfo->file($path))[1] ?? pathinfo($path, PATHINFO_EXTENSION);
|
||||||
|
}
|
||||||
|
$ext = strtolower($ext);
|
||||||
|
|
||||||
|
// Animated / vector formats have no meaningful resize — return source.
|
||||||
|
if (in_array($ext, ['gif', 'svg', 'svg+xml'], true)) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize dimensions
|
// Normalize dimensions
|
||||||
if ($width !== null && strtolower((string) $width) !== 'auto') {
|
$autoW = is_string($width) && strtolower($width) === 'auto';
|
||||||
$width = max(1, (int) $width);
|
$autoH = is_string($height) && strtolower($height) === 'auto';
|
||||||
}
|
$width = $autoW ? 'auto' : (int) ($width ?: $height ?: 0);
|
||||||
if ($height !== null && strtolower((string) $height) !== 'auto') {
|
$height = $autoH ? 'auto' : (int) ($height ?: $width ?: 0);
|
||||||
$height = max(1, (int) $height);
|
if ($width === 0 && $height === 0) {
|
||||||
|
return $path;
|
||||||
}
|
}
|
||||||
|
|
||||||
$width = $width ?: $height;
|
// Round to nearest step (caches fewer permutations)
|
||||||
$height = $height ?: $width;
|
$roundTo = (int) config('files.optimization.round_to', 50);
|
||||||
|
if ($rounding && $roundTo > 0) {
|
||||||
// Round to nearest step
|
if ($width !== 'auto') {
|
||||||
$roundTo = config('files.optimization.round_to', 50);
|
$width = (int) (ceil($width / $roundTo) * $roundTo);
|
||||||
if ($rounding) {
|
}
|
||||||
$width = ($width === 'auto') ? $width : (int) (ceil((int) $width / $roundTo) * $roundTo);
|
if ($height !== 'auto') {
|
||||||
$height = ($height === 'auto') ? $height : (int) (ceil((int) $height / $roundTo) * $roundTo);
|
$height = (int) (ceil($height / $roundTo) * $roundTo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build cache key
|
// Build cache key
|
||||||
|
|
@ -365,44 +387,132 @@ class File extends Model
|
||||||
if ($quality !== null && $quality > 0 && $quality < 100) {
|
if ($quality !== null && $quality > 0 && $quality < 100) {
|
||||||
$cacheKey .= '.q' . $quality;
|
$cacheKey .= '.q' . $quality;
|
||||||
}
|
}
|
||||||
|
if ($canvasX || $canvasY) {
|
||||||
|
$cacheKey .= '.c' . ($canvasX ?: 0) . 'x' . ($canvasY ?: 0);
|
||||||
|
}
|
||||||
|
if ($offsetX || $offsetY) {
|
||||||
|
$cacheKey .= '.o' . $offsetX . 'x' . $offsetY;
|
||||||
|
}
|
||||||
|
|
||||||
$cachedPath = $resizedDir . '/' . basename($path, '.' . $ext) . '.' . $cacheKey . '.' . $ext;
|
$cachedPath = $resizedDir . '/' . basename($path, '.' . $ext) . '.' . $cacheKey . '.' . $ext;
|
||||||
if ($toWebp && $this->isImage()) {
|
if ($toWebp && $this->isImage()) {
|
||||||
$cachedPath .= '.webp';
|
$cachedPath .= '.webp';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return cached version if available
|
// Cache hit — touch mtime for LRU-style cleanup commands.
|
||||||
if ($cached && file_exists($cachedPath)) {
|
if ($cached && file_exists($cachedPath)) {
|
||||||
|
@touch($cachedPath);
|
||||||
|
|
||||||
return $cachedPath;
|
return $cachedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate resized version
|
// Write to a temp file and atomically rename into place. A concurrent
|
||||||
copy($path, $cachedPath);
|
// request must never observe a partially-written cache file, otherwise
|
||||||
|
// downstream decoders will serve broken bytes (IDAT CRC errors on PNG,
|
||||||
|
// truncated zlib on WebP, etc.).
|
||||||
|
$pi = pathinfo($cachedPath);
|
||||||
|
$tmpPath = $pi['dirname'] . '/' . $pi['filename']
|
||||||
|
. '.tmp-' . bin2hex(random_bytes(4))
|
||||||
|
. (isset($pi['extension']) ? '.' . $pi['extension'] : '');
|
||||||
|
|
||||||
$fit = match ($position) {
|
try {
|
||||||
'contain' => \Spatie\Image\Enums\Fit::Contain,
|
copy($path, $tmpPath);
|
||||||
'fill' => \Spatie\Image\Enums\Fit::Fill,
|
|
||||||
'max' => \Spatie\Image\Enums\Fit::Max,
|
|
||||||
'stretch' => \Spatie\Image\Enums\Fit::Stretch,
|
|
||||||
default => \Spatie\Image\Enums\Fit::Crop,
|
|
||||||
};
|
|
||||||
|
|
||||||
$image = \Spatie\Image\Image::load($cachedPath)
|
// Some PNGs in storage ship with a miscomputed IDAT CRC, or were
|
||||||
->fit(
|
// uploaded truncated. Browsers decode them fine but libpng (used by
|
||||||
$fit,
|
// both GD and ImageMagick) refuses. Make a best-effort attempt to
|
||||||
($width === 'auto') ? null : (int) $width,
|
// rewrite the tmp file via a lenient decoder before passing to
|
||||||
($height === 'auto') ? null : (int) $height,
|
// Spatie, so intact-but-pedantically-invalid PNGs still resize.
|
||||||
);
|
if ($ext === 'png') {
|
||||||
|
$this->healCorruptPng($tmpPath);
|
||||||
|
}
|
||||||
|
|
||||||
if ($quality !== null && $quality > 0) {
|
$fit = match ($position) {
|
||||||
$image->quality(min(100, max(1, $quality)));
|
'contain' => \Spatie\Image\Enums\Fit::Contain,
|
||||||
|
'fill' => \Spatie\Image\Enums\Fit::Fill,
|
||||||
|
'max' => \Spatie\Image\Enums\Fit::Max,
|
||||||
|
'stretch' => \Spatie\Image\Enums\Fit::Stretch,
|
||||||
|
default => \Spatie\Image\Enums\Fit::Crop,
|
||||||
|
};
|
||||||
|
|
||||||
|
$image = \Spatie\Image\Image::load($tmpPath)
|
||||||
|
->fit(
|
||||||
|
$fit,
|
||||||
|
($width === 'auto') ? null : (int) $width,
|
||||||
|
($height === 'auto') ? null : (int) $height,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($canvasX || $canvasY) {
|
||||||
|
$cWidth = $canvasX ? (int) $canvasX : (int) $width;
|
||||||
|
$cHeight = $canvasY ? (int) $canvasY : (int) $height;
|
||||||
|
$image->resizeCanvas(
|
||||||
|
$cWidth,
|
||||||
|
$cHeight,
|
||||||
|
\Spatie\Image\Enums\AlignPosition::Center,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($quality !== null && $quality > 0) {
|
||||||
|
$image->quality(min(100, max(1, $quality)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$image->save($tmpPath);
|
||||||
|
|
||||||
|
rename($tmpPath, $cachedPath);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if (isset($tmpPath) && file_exists($tmpPath)) {
|
||||||
|
@unlink($tmpPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unrecoverable decode failure — typically a truncated upload.
|
||||||
|
// Fall back to the original bytes so the caller still gets a 200
|
||||||
|
// and the browser renders whatever it can, instead of a 500.
|
||||||
|
if (function_exists('logger')) {
|
||||||
|
logger()->warning('laravel-files: resize failed; serving original as fallback', [
|
||||||
|
'file' => $path,
|
||||||
|
'size' => $width . 'x' . $height,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
}
|
}
|
||||||
|
|
||||||
$image->save($cachedPath);
|
|
||||||
|
|
||||||
return $cachedPath;
|
return $cachedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to rewrite a PNG file with valid CRCs, using whichever lenient
|
||||||
|
* decoder is available. Silently no-ops on failure — the caller will
|
||||||
|
* handle that via the normal fallback path.
|
||||||
|
*/
|
||||||
|
protected function healCorruptPng(string $path): void
|
||||||
|
{
|
||||||
|
if (function_exists('imagecreatefrompng')) {
|
||||||
|
$gd = @imagecreatefrompng($path);
|
||||||
|
if ($gd !== false) {
|
||||||
|
imagesavealpha($gd, true);
|
||||||
|
imagepng($gd, $path);
|
||||||
|
imagedestroy($gd);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (class_exists(\Imagick::class)) {
|
||||||
|
try {
|
||||||
|
$pre = new \Imagick;
|
||||||
|
$pre->setOption('png:preserve-corrupt-image', 'true');
|
||||||
|
$pre->readImage($path);
|
||||||
|
$pre->setImageFormat('png');
|
||||||
|
$pre->writeImage($path);
|
||||||
|
$pre->clear();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Let the main pipeline throw and take the fallback branch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Cleanup
|
| Cleanup
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue