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 } : {}) // Applies the EXIF orientation to the pixels, and must come before // resize (#300). // // A camera does not turn its sensor data round. It writes the pixels as // the sensor read them and sets an Orientation tag saying which way up // they go, and every viewer honours that — which is why a portrait // photograph looks upright to the person who took it and to the person // who attached it. Stripping the tag without applying it does not leave // the photo alone: it leaves the pixels sideways with nothing left to // explain them, and the sender's upright photo arrives on its side. // // Before resize because the resize bounds are width and height, and for // a portrait photo those are the wrong way round until the rotation has // happened. A 3000x4000 photograph stored as 4000x3000 would otherwise // be bounded on the wrong axis. // // No argument: that is what makes it read the tag rather than turn the // image by a fixed amount. .rotate() .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; } } /** Which way a quarter turn goes, in the words the buttons use. */ export type RotateDirection = 'left' | 'right'; /** * sharp reads a positive angle as clockwise, so the sign is the whole of the * mapping — and getting it backwards produces a control that works perfectly * and does the opposite of what its label says, which no dimension assertion * would ever catch. */ const QUARTER_TURN: Record = { left: -90, right: 90 }; /** * Turns the file at `filePath` a quarter turn, in its own format. * * The remedy for photos uploaded before #300 taught the re-encode to apply EXIF * orientation instead of discarding it. Those files cannot be repaired * automatically — the tag that said which way up they went is gone — so a * person has to look at each one and decide. * * Rewrites the pixels rather than recording an angle. An angle would keep the * bytes pristine, but it would put an obligation on every consumer — the * storefront, both admin screens, the drafting worker's photo reader, and the * rembg sidecar — and any one that forgot would show the photo sideways. The * sidecar in particular is not ours to teach. * * Same temporary-file-and-rename shape as `reencodeInPlace`, for the same two * reasons: sharp cannot read and write one path in a single pass, and a process * that dies mid-write must leave the old photo intact rather than half a new * one. * * No `resize`. The file went through `reencodeInPlace` on upload and is already * within bounds, so re-applying the cap would be a second lossy pass buying * nothing. No metadata handling either: the re-encode already stripped it, and * there is nothing left to strip. */ export async function rotateInPlace( filePath: string, mimetype: string, direction: RotateDirection ): Promise { const temporary = `${filePath}.rotating`; try { await encoderFor( // Exactly the `animated` argument reencodeInPlace uses, and the reason is // sharper here. Reading an animated WebP without it decodes the first // frame alone, so omitting it would silently write back a still and // destroy the animation while reporting success. With it, sharp refuses // the rotation outright — multi-page images can only be turned 180° — // which is the honest answer and surfaces as a 500 with the file // untouched. A still WebP has one page and turns normally. sharp(filePath, mimetype === 'image/webp' ? { animated: true } : {}).rotate( QUARTER_TURN[direction] ), mimetype ).toFile(temporary); await fs.rename(temporary, filePath); } catch (err) { await fs.unlink(temporary).catch(() => undefined); throw err; } }