Files
redefined-designs/backend/src/imageProcessing.ts
T
bermudalambandClaude Opus 5 5c0c7186ff feat(images): turn a stored photo a quarter turn (#301)
The file half of the remedy for what #300 could only stop. Fixing the EXIF strip means new uploads arrive the way the sender saw them; it cannot repair what is already stored, because the tag that said which way up the pixels went is gone. Those photos need a person to look at each one and turn it.

Rewrites the pixels rather than recording an angle, because an angle obliges every consumer to honour it — the storefront, both admin screens, the drafting worker's photo reader, and the rembg sidecar — and any one that forgets shows the photo sideways. The sidecar is not ours to teach.

Left is anticlockwise and right is clockwise, which is rotate(-90) and rotate(90); sharp reads a positive angle as clockwise. The direction test asserts a pixel rather than a dimension, because dimensions swap whichever way the turn goes — a reversed sign would pass every size assertion and ship a control that does the opposite of its label.

The animated WebP case is the one that could destroy someone's file quietly. Reading such a file without the animated flag succeeds and hands back the first frame alone, so a rotation that omitted it would write a still back over the animation and report success. Passing the same flag reencodeInPlace passes makes sharp refuse instead — multi-page images turn only by 180° — which is the honest answer and leaves the file untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:37:41 -05:00

192 lines
8.2 KiB
TypeScript

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<void> {
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<RotateDirection, number> = { 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<void> {
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;
}
}