import sharp, { type Sharp } from 'sharp'; import { promises as fs } from 'fs'; /** * Rebuilding an uploaded image so it carries nothing but the picture. * * A file arrives as the camera wrote it, and a camera writes EXIF — which * routinely includes the coordinates the photo was taken at. Those files are * served publicly from /uploads/, so an unmodified product photo publishes the * location it was taken. Nobody sending in a photograph of a vase expects that. * * The fix is to re-encode rather than to delete tags. Deleting requires knowing * every tag that could carry something sensitive, across formats and camera * makers, forever. Re-encoding builds a new file from the decoded pixels, so * there is nothing left that could have been missed — the same reasoning that * makes uploadTypes.ts an allowlist rather than a denylist. * * Format is deliberately preserved. Converting to WebP would compress better, * but it changes stored extensions, and therefore item_images.image_path, and * therefore turns the backfill into a rename with a window where rows point at * files that no longer exist. See #226. */ /** Comfortably larger than anything the storefront renders. */ export const MAX_DIMENSION = 2000; /** Where further reduction starts to show on a photograph. */ export const QUALITY = 82; interface ImageFacts { width?: number; height?: number; exif?: unknown; } /** * Whether a file still needs rebuilding. * * Pure, and the backfill's entire idempotency argument: a file with no EXIF * that is already within bounds is already in its final state, so a re-run * skips it rather than putting it through a second lossy pass. Anything * unreadable is processed rather than skipped — a file we cannot describe is * not one to assume is safe. */ export function needsProcessing(meta: ImageFacts): boolean { if (meta.exif !== undefined && meta.exif !== null) return true; if (meta.width === undefined || meta.height === undefined) return true; return meta.width > MAX_DIMENSION || meta.height > MAX_DIMENSION; } function encoderFor(instance: Sharp, mimetype: string): Sharp { switch (mimetype) { case 'image/jpeg': return instance.jpeg({ quality: QUALITY }); case 'image/webp': return instance.webp({ quality: QUALITY }); case 'image/png': // PNG is lossless, so quality does not apply and this will not shrink // much. It still strips EXIF and still bounds the dimensions, which are // the two things being bought here. return instance.png({ compressionLevel: 9 }); default: // Unreachable: the allowlist in uploadTypes.ts is these three. Throwing // rather than passing the file through unmodified, because "we did not // recognise it so we left the metadata in" is the failure mode this // module exists to make impossible. throw new Error(`cannot re-encode unsupported type ${mimetype}`); } } /** * Rewrites the file at `filePath`, in its own format, stripped and bounded. * * Writes to a sibling temporary file and renames over the original, because * writing in place would leave a half-written image being served if the process * died mid-write — and sharp cannot read and write the same path in one pass * anyway. * * `withoutEnlargement` so a small image is not blown up to the cap: the ceiling * is a maximum, not a target. */ export async function reencodeInPlace(filePath: string, mimetype: string): Promise { const temporary = `${filePath}.reencoding`; try { await encoderFor( // `animated` only for WebP, which is the one allowed type that can carry // more than one frame. Reading an animated WebP without it decodes the // first frame alone and silently writes back a still — destroying the // uploader's image while reporting success. It is not set unconditionally // because it changes how `resize` interprets height (the full frame // strip, not one frame), which would be wrong for the other two. sharp(filePath, mimetype === 'image/webp' ? { animated: true } : {}).resize({ width: MAX_DIMENSION, height: MAX_DIMENSION, fit: 'inside', withoutEnlargement: true }), mimetype // No withMetadata(): omitting it is what drops EXIF, ICC and everything // else. Calling it would put the metadata back. ).toFile(temporary); await fs.rename(temporary, filePath); } catch (err) { await fs.unlink(temporary).catch(() => undefined); throw err; } }