Clients who need smaller files no longer make the photographer re-export. Two capabilities, both off by default. STANDARD RESOLUTION — the size a gallery hands out for every ordinary download (single, selected, download-all). Global default in Settings, overridable per gallery with the NULL=inherit tri-state. The pre-built download-all zip is built AT the standard resolution, so changing it invalidates those archives, including a fan-out to inheriting galleries. RESOLUTION PICKER — opt-in modal letting guests choose a different size. Custom archives are built as a DB-backed job the client polls, never cached. The picker never offers a size above the standard, and Original reappears only when the admin explicitly allows it. Resize is fit:'inside' + withoutEnlargement — aspect preserved, never upscaled — applied before the watermark, since the mark is sized relative to its input. Three rounds of external review hardened this: job archives are bound to the requester's visibility scope and re-validated at delivery, the streamed download-all path applies the cap, queue admission is bounded, and rejected resolutions no longer inflate download stats. Closes #858.
This commit is contained in:
@@ -736,7 +736,75 @@ async function extractCaptureDate(imagePath) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downscale to fit inside a box, for the download-resolution feature (#858).
|
||||
*
|
||||
* `fit: 'inside'` + `withoutEnlargement` is exactly the "up to" semantic the
|
||||
* requester asked for on #858: the box is a maximum, aspect ratio is kept
|
||||
* (so a 3:2 box leaves a 4:3 photo slightly smaller than the box on one
|
||||
* edge), and an image already smaller than the box is returned untouched
|
||||
* rather than upscaled into mush.
|
||||
*
|
||||
* Takes and returns a Buffer so callers can chain resize → watermark without
|
||||
* a tmp file. Returns the input unchanged when `box` is null ('original').
|
||||
* Never throws: on a corrupt/undecodable source it logs and returns the input,
|
||||
* because failing a download outright is worse than serving the full size.
|
||||
*/
|
||||
async function resizeToBox(inputBuffer, box, options = {}) {
|
||||
if (!box || !box.width || !box.height) return inputBuffer;
|
||||
try {
|
||||
const probe = sharp(inputBuffer, { limitInputPixels: 268402689, failOn: 'none' });
|
||||
const metadata = await probe.metadata();
|
||||
// Already inside the box — hand back the original bytes rather than
|
||||
// re-encoding, which would only cost quality and CPU.
|
||||
if (metadata.width && metadata.height
|
||||
&& metadata.width <= box.width && metadata.height <= box.height) {
|
||||
return inputBuffer;
|
||||
}
|
||||
|
||||
const format = (metadata.format || '').toLowerCase();
|
||||
// Animated sources must be re-opened with `animated: true`, otherwise
|
||||
// sharp keeps only the first frame and the download silently loses its
|
||||
// animation. `.rotate()` would flatten an animated source, so it is
|
||||
// applied only to stills (where EXIF orientation actually exists).
|
||||
const animated = (metadata.pages || 1) > 1;
|
||||
const image = animated
|
||||
? sharp(inputBuffer, { limitInputPixels: 268402689, failOn: 'none', animated: true })
|
||||
: probe.rotate();
|
||||
|
||||
let pipeline = image
|
||||
.resize(box.width, box.height, { fit: 'inside', withoutEnlargement: true });
|
||||
|
||||
// Re-encode in the SOURCE format. The download routes keep the original
|
||||
// filename and mime type, so emitting JPEG for a .gif would ship
|
||||
// mislabelled bytes. GIF is an accepted upload format (multerConfig.photos).
|
||||
//
|
||||
// HEIC/HEIF is the exception: sharp builds generally cannot ENCODE it, and
|
||||
// the browser can't display the original anyway (see originalNeedsPreview
|
||||
// in gallery.js). Rather than emit JPEG bytes under a .heic name, leave
|
||||
// those downloads at original size — correct-but-larger beats
|
||||
// mislabelled-and-broken.
|
||||
if (format === 'heif' || format === 'heic') {
|
||||
return inputBuffer;
|
||||
}
|
||||
if (format === 'png') {
|
||||
pipeline = pipeline.png({ compressionLevel: 6 });
|
||||
} else if (format === 'webp') {
|
||||
pipeline = pipeline.webp({ quality: options.quality || 90 });
|
||||
} else if (format === 'gif') {
|
||||
pipeline = pipeline.gif();
|
||||
} else {
|
||||
pipeline = pipeline.jpeg({ quality: options.quality || 90, mozjpeg: true });
|
||||
}
|
||||
return await pipeline.toBuffer();
|
||||
} catch (e) {
|
||||
logger.warn(`resizeToBox failed (${box.width}x${box.height}), serving original: ${e.message}`);
|
||||
return inputBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resizeToBox,
|
||||
generateThumbnail,
|
||||
isThumbnailValid,
|
||||
ensureThumbnail,
|
||||
|
||||
Reference in New Issue
Block a user