feat(uploads): re-encode images to strip metadata and bound dimensions (#226)

Re-encoding rather than deleting tags. Deleting requires knowing every tag that could carry something sensitive, across formats and camera makers, indefinitely; rebuilding the file from decoded pixels leaves nothing that could have been missed. The same reasoning that makes uploadTypes.ts an allowlist rather than a denylist.

`needsProcessing` is pure and separately tested because it is the whole of the backfill's idempotency argument: a file with no EXIF already inside the bounds is already in its final state, so a second run skips it instead of putting it through another lossy pass. Being wrong there degrades every image a little more on every run. Anything sharp cannot describe is processed rather than skipped, since a file we understand least is not one to assume is safe.

Verified end to end on a real image before wiring anything up: 3000x2000 with EXIF present became 2000x1333 with EXIF absent, and no temporary file was left behind.

Corrects something this README claimed an hour ago. Installing under a Node below 20.9.0 does produce a broken sharp, because npm skips the optional platform binary when the engine check fails and still reports success. But once that binary is present sharp loads and runs fine on 18.16.1 — `engines` is enforced at install time, not at require time. The README said the runtime was blocked, which would have sent someone switching Node versions to fix a problem that only the install created.

Ref #226
This commit is contained in:
2026-08-29 10:55:33 -05:00
parent 7d45b69305
commit e85be0f970
3 changed files with 142 additions and 1 deletions
@@ -0,0 +1,31 @@
import { needsProcessing, MAX_DIMENSION } from '../../src/imageProcessing';
// The whole of the skip/process policy, kept pure so the backfill's
// idempotency can be reasoned about without a filesystem. The backfill is
// lossy and irreversible, so being wrong here is expensive.
describe('needsProcessing', () => {
it('processes anything carrying EXIF, however small', () => {
expect(needsProcessing({ width: 10, height: 10, exif: Buffer.from('x') })).toBe(true);
});
it('processes an oversized image even with no EXIF', () => {
expect(needsProcessing({ width: MAX_DIMENSION + 1, height: 100 })).toBe(true);
});
it('processes an image oversized on either axis', () => {
expect(needsProcessing({ width: 100, height: MAX_DIMENSION + 1 })).toBe(true);
});
// The idempotency property the backfill depends on: a file already stripped
// and already within bounds is left alone, so a second run cannot put it
// through another lossy pass.
it('leaves a stripped, in-bounds image alone', () => {
expect(needsProcessing({ width: MAX_DIMENSION, height: MAX_DIMENSION })).toBe(false);
});
// Unknown dimensions mean sharp could not read it as an image. Processing is
// the safe answer: the alternative is skipping a file we understand least.
it('processes an image whose dimensions could not be read', () => {
expect(needsProcessing({})).toBe(true);
});
});