Closes the privacy exposure in #226: uploaded photos carry the GPS coordinates they were taken at, and are served publicly from /uploads/.
Four commits, implementing Tasks 1–4 of docs/superpowers/plans/2026-08-29-strip-exif-and-reencode.md. Task 5 — the deployment — is deliberately not in here. It is the irreversible part and it needs decisions and credentials I do not have. Steps at the bottom.
What changed
Commit
What
7d45b69
sharp as a production dependency, proven in the actual container
e85be0f
src/imageProcessing.ts — the re-encode policy, with needsProcessing pure and separately tested
aecccef
Wired into the uploadImages middleware, so every upload path is covered
502d56d
scripts/backfill-image-reencode.ts for the photos already on the storefront
Re-encoding rather than deleting tags. Deleting means 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.
Hooked into uploadImages rather than the routes because that middleware is the single choke point every upload passes through. The admin create and update routes are covered, and #222's intake route will inherit it rather than having to remember.
New uploads, asserted in exifStripping.integration.test.ts — the fixture is a JPEG built with real GPS tags, and the test asserts the fixture has EXIF before asserting the stored file does not, so it cannot pass while proving nothing:
no EXIF once stored
bounded to 2000px, 3000x2000 becoming 2000x1333
format preserved, so the stored extension still describes the file
a small image is not enlarged
no .reencoding temporary files left behind
The backfill, exercised against a real row and a real file:
REPORT: would process /uploads/legacy-test.jpg (3000x2000, exif present, 35760 bytes)
file missing for /uploads/does-not-exist.jpg
unrecognised extension, skipping: /uploads/odd-file.tiff
APPLY: /uploads/legacy-test.jpg: 35760 -> 16019 bytes
AGAIN: skipped: 1, processed: 0
That third run is the idempotency guarantee, demonstrated rather than argued: a second --apply does not put an already-stripped file through another lossy pass. Afterwards the metadata was gone, the format was still JPEG, no temporary file remained, and item_images.image_path was untouched — preserving the format means the backfill writes no database rows at all, so there is no window where a row points at a file that no longer exists.
Things worth your eye
Format is preserved rather than normalised to WebP. Converting compresses better but changes every stored extension, and therefore image_path, turning the backfill into a rename plus a database write. The cost is that PNG photographs will not shrink much; they still lose their EXIF and still get bounded. Argued in the plan's decisions table.
sharp raises the install floor to Node 20.9. Its platform binary is an optional dependency, so npm skips it when the engine check fails and still reports success — installing on this machine's default 18.16.1 produces a node_modules that looks complete and throws at require time, naming a runtime rather than a version. engines is now declared and the README's Node section says what the failure looks like. Once installed, sharp runs fine on 18.16.1; this is purely about how the install was done.
One flake found, filed rather than fixed.uploadValidation failed once in three full runs. I checked whether I caused it by stashing the change — it passed 12/12 without it — then diagnosed rather than re-running until green. It is a pre-existing race in the test: discardUnlessAccepted cleans up in an unawaited void discardUploads(...) on res.on('close'), so asserting on the directory immediately after the response has always been able to read it before the unlink lands. Re-encoding adds enough work to lose that race occasionally. The production property is intact. Filed as #228 with both fix options, since changing #180's machinery is outside this issue.
Task 5 — over to you
Not doable from here, and the order matters:
Deploy to QA; upload a photo with EXIF and confirm it comes back stripped.
docker exec <qa> npm run backfill:images — read the report. A count of zero, or one far larger than the catalogue, means the path resolution is wrong rather than the images.
--apply on QA, then look at the images, including a detail view. This is the only check no assertion covers: whether they still look good enough to sell from.
Back up production and confirm the backup restores. The uploads volume, not only the database — the database is untouched by this and the files are the only thing at risk.
Report, then apply, on production.
Confirm the exposure is closed by fetching a live image over HTTP and reading its metadata. exif: undefined is the assertion this whole issue exists to be able to make.
Closes the privacy exposure in #226: uploaded photos carry the GPS coordinates they were taken at, and are served publicly from `/uploads/`.
Four commits, implementing Tasks 1–4 of `docs/superpowers/plans/2026-08-29-strip-exif-and-reencode.md`. **Task 5 — the deployment — is deliberately not in here.** It is the irreversible part and it needs decisions and credentials I do not have. Steps at the bottom.
## What changed
| Commit | What |
| --- | --- |
| `7d45b69` | `sharp` as a production dependency, proven in the actual container |
| `e85be0f` | `src/imageProcessing.ts` — the re-encode policy, with `needsProcessing` pure and separately tested |
| `aecccef` | Wired into the `uploadImages` middleware, so every upload path is covered |
| `502d56d` | `scripts/backfill-image-reencode.ts` for the photos already on the storefront |
Re-encoding rather than deleting tags. Deleting means 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.
Hooked into `uploadImages` rather than the routes because that middleware is the single choke point every upload passes through. The admin create and update routes are covered, and #222's intake route will inherit it rather than having to remember.
## Verification
Backend: **285 unit**, **260 integration**, lint clean (3 pre-existing warnings, unchanged), `tsc` clean.
New uploads, asserted in `exifStripping.integration.test.ts` — the fixture is a JPEG built with real GPS tags, and the test asserts the fixture *has* EXIF before asserting the stored file does not, so it cannot pass while proving nothing:
- no EXIF once stored
- bounded to 2000px, 3000x2000 becoming 2000x1333
- format preserved, so the stored extension still describes the file
- a small image is not enlarged
- no `.reencoding` temporary files left behind
The backfill, exercised against a real row and a real file:
```
REPORT: would process /uploads/legacy-test.jpg (3000x2000, exif present, 35760 bytes)
file missing for /uploads/does-not-exist.jpg
unrecognised extension, skipping: /uploads/odd-file.tiff
APPLY: /uploads/legacy-test.jpg: 35760 -> 16019 bytes
AGAIN: skipped: 1, processed: 0
```
That third run is the idempotency guarantee, demonstrated rather than argued: a second `--apply` does not put an already-stripped file through another lossy pass. Afterwards the metadata was gone, the format was still JPEG, no temporary file remained, and **`item_images.image_path` was untouched** — preserving the format means the backfill writes no database rows at all, so there is no window where a row points at a file that no longer exists.
## Things worth your eye
**Format is preserved rather than normalised to WebP.** Converting compresses better but changes every stored extension, and therefore `image_path`, turning the backfill into a rename plus a database write. The cost is that PNG photographs will not shrink much; they still lose their EXIF and still get bounded. Argued in the plan's decisions table.
**`sharp` raises the install floor to Node 20.9.** Its platform binary is an *optional* dependency, so npm skips it when the engine check fails and still reports success — installing on this machine's default 18.16.1 produces a `node_modules` that looks complete and throws at require time, naming a runtime rather than a version. `engines` is now declared and the README's Node section says what the failure looks like. Once installed, sharp runs fine on 18.16.1; this is purely about how the install was done.
**One flake found, filed rather than fixed.** `uploadValidation` failed once in three full runs. I checked whether I caused it by stashing the change — it passed 12/12 without it — then diagnosed rather than re-running until green. It is a pre-existing race in the *test*: `discardUnlessAccepted` cleans up in an unawaited `void discardUploads(...)` on `res.on('close')`, so asserting on the directory immediately after the response has always been able to read it before the unlink lands. Re-encoding adds enough work to lose that race occasionally. The production property is intact. Filed as #228 with both fix options, since changing #180's machinery is outside this issue.
## Task 5 — over to you
Not doable from here, and the order matters:
1. Deploy to QA; upload a photo with EXIF and confirm it comes back stripped.
2. `docker exec <qa> npm run backfill:images` — read the report. A count of zero, or one far larger than the catalogue, means the path resolution is wrong rather than the images.
3. `--apply` on QA, then **look at the images**, including a detail view. This is the only check no assertion covers: whether they still look good enough to sell from.
4. **Back up production and confirm the backup restores.** The uploads volume, not only the database — the database is untouched by this and the files are the only thing at risk.
5. Report, then apply, on production.
6. Confirm the exposure is closed by fetching a live image over HTTP and reading its metadata. `exif: undefined` is the assertion this whole issue exists to be able to make.
Closes #226
Verified where it actually has to run rather than only here: the production image builds and `require('sharp')` succeeds inside it on Node v20.20.2, linux/x64, with libvips 8.18.6 and `withExif` available, needing no build toolchain. The architecture question is already settled by this same node:20-bookworm-slim base running in production today, and sharp ships glibc prebuilds for both linux-x64 and linux-arm64, so it adds no constraint that deployment did not already satisfy.
Installing it locally found a trap worth recording. sharp requires Node >=20.9.0 and its platform binary is an *optional* dependency, so npm skips it when the engine check fails and still reports success. Installed under this machine's default 18.16.1 the result is a node_modules that looks complete and throws `Could not load the "sharp" module using the win32-x64 runtime` at require time — which reads as a broken package rather than as a wrong Node version. The fix is `npm install --include=optional sharp` under Node 20+, and the prevention is using start-local.ps1 or run-tests.ps1, which switch first.
`engines` is now declared so npm at least warns, and the README's existing Node 20 section says what the failure looks like, since the error message names a runtime rather than a version and points nowhere useful.
The lockfile carries every platform variant including linux-x64 and linux-arm64, so a build on another platform resolves correctly. The Dockerfile does not copy the lockfile at all and installs fresh, so this matters for contributors rather than for the image.
Ref #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
Hooked into uploadImages rather than into the routes. That middleware is where verifyUploadedImages already runs and is the single choke point every upload path passes through, so the admin create and update routes are both covered and the intake route from #222 will inherit it rather than having to remember. The same reasoning discardUnlessAccepted already gives for being a hook instead of a call.
Runs after verification, deliberately: re-encoding a file whose bytes do not match its declared type would be work on something already refused, and sharp's error would replace the clearer message that check produces. A re-encode failure refuses the upload rather than storing the original, because the one case where a photo keeps the coordinates it was taken at should not be the case nobody was told about.
The test builds a JPEG carrying GPS tags rather than committing a binary fixture, so what it contains is readable, and it asserts the fixture really carries EXIF before asserting the stored file does not — otherwise the test would pass while proving nothing. GPS tags go in IFD3, which is the GPS IFD as libvips names it; sharp's Exif type has no separate GPS key, and putting them in IFD0 would have produced EXIF without producing the tags this issue is about.
Backend suites: 285 unit, 260 integration, lint clean, build clean.
One caveat worth recording. Across three full integration runs, `uploadValidation` failed once on "removes the upload when the request is refused for its other fields". It is a pre-existing race rather than a regression: discardUnlessAccepted cleans up in an unawaited `void discardUploads(...)` inside a `res.on('close')` handler, so a test asserting on the directory immediately after the response has always been able to observe the state before the unlink lands. Re-encoding adds enough libvips work to lose that race occasionally where it previously did not. The property still holds in production, where the process keeps running and the unlink completes. Filed separately rather than fixed here.
Ref #226
Stripping new uploads does nothing for the catalogue that is already on the storefront, which is where the exposure actually lives today. This is the other half.
Reports by default and rewrites nothing without --apply, because the transform is lossy and there is no undo. Idempotency comes from `needsProcessing` rather than from a marker or a schema change: 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. Proven rather than assumed — a second --apply immediately after the first reports skipped 1, processed 0.
Verified end to end against a real row and a real file. 3000x2000 carrying GPS EXIF became 2000x1333 with the metadata gone, 35760 bytes down to 16019, the format preserved, no temporary file left behind, and `item_images.image_path` untouched. That last part is what preserving the format bought: the backfill rewrites bytes and writes nothing to the database, so there is no window where a row points at a file that no longer exists.
Both degenerate branches are exercised too, since a script that dies partway through a catalogue leaves the rest of it exposed: a row pointing at a missing file and a row with an extension the application would refuse to serve are each reported and counted, and the run continues.
`handleRow` is split out of `run` for cognitive complexity, and while doing that a miscount was introduced and caught — incrementing `processed` before the rewrite meant a file that threw would have been counted as both processed and failed, which makes the summary unreadable at the moment it matters most.
Ref #226
main gained the Drizzle spike (#216) after this branch was cut, and both changes add a production dependency, so `backend/package-lock.json` conflicted. `backend/package.json` merged cleanly and carries both `drizzle-orm` and `sharp`.
The lockfile was regenerated rather than hand-merged: main's version taken as the base, then `npm install` re-resolved it. That install was deliberately run under Node 24 rather than the machine's default 18.16.1, because sharp's platform binaries are optional dependencies that npm silently omits when the engine check fails — regenerating this file on Node 18 would have quietly dropped every `@img/sharp-*` entry and produced a lockfile that installs a sharp which cannot load. Verified afterwards that linux-x64, linux-arm64 and win32-x64 are all present and that drizzle-orm survived.
Backend: 285 unit tests pass, tsc clean. Lint reports six warnings rather than three; the three new ones are in src/db-drizzle from the spike, not from this branch.
Ref #226
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes the privacy exposure in #226: uploaded photos carry the GPS coordinates they were taken at, and are served publicly from
/uploads/.Four commits, implementing Tasks 1–4 of
docs/superpowers/plans/2026-08-29-strip-exif-and-reencode.md. Task 5 — the deployment — is deliberately not in here. It is the irreversible part and it needs decisions and credentials I do not have. Steps at the bottom.What changed
7d45b69sharpas a production dependency, proven in the actual containere85be0fsrc/imageProcessing.ts— the re-encode policy, withneedsProcessingpure and separately testedaecccefuploadImagesmiddleware, so every upload path is covered502d56dscripts/backfill-image-reencode.tsfor the photos already on the storefrontRe-encoding rather than deleting tags. Deleting means 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.tsan allowlist.Hooked into
uploadImagesrather than the routes because that middleware is the single choke point every upload passes through. The admin create and update routes are covered, and #222's intake route will inherit it rather than having to remember.Verification
Backend: 285 unit, 260 integration, lint clean (3 pre-existing warnings, unchanged),
tscclean.New uploads, asserted in
exifStripping.integration.test.ts— the fixture is a JPEG built with real GPS tags, and the test asserts the fixture has EXIF before asserting the stored file does not, so it cannot pass while proving nothing:.reencodingtemporary files left behindThe backfill, exercised against a real row and a real file:
That third run is the idempotency guarantee, demonstrated rather than argued: a second
--applydoes not put an already-stripped file through another lossy pass. Afterwards the metadata was gone, the format was still JPEG, no temporary file remained, anditem_images.image_pathwas untouched — preserving the format means the backfill writes no database rows at all, so there is no window where a row points at a file that no longer exists.Things worth your eye
Format is preserved rather than normalised to WebP. Converting compresses better but changes every stored extension, and therefore
image_path, turning the backfill into a rename plus a database write. The cost is that PNG photographs will not shrink much; they still lose their EXIF and still get bounded. Argued in the plan's decisions table.sharpraises the install floor to Node 20.9. Its platform binary is an optional dependency, so npm skips it when the engine check fails and still reports success — installing on this machine's default 18.16.1 produces anode_modulesthat looks complete and throws at require time, naming a runtime rather than a version.enginesis now declared and the README's Node section says what the failure looks like. Once installed, sharp runs fine on 18.16.1; this is purely about how the install was done.One flake found, filed rather than fixed.
uploadValidationfailed once in three full runs. I checked whether I caused it by stashing the change — it passed 12/12 without it — then diagnosed rather than re-running until green. It is a pre-existing race in the test:discardUnlessAcceptedcleans up in an unawaitedvoid discardUploads(...)onres.on('close'), so asserting on the directory immediately after the response has always been able to read it before the unlink lands. Re-encoding adds enough work to lose that race occasionally. The production property is intact. Filed as #228 with both fix options, since changing #180's machinery is outside this issue.Task 5 — over to you
Not doable from here, and the order matters:
docker exec <qa> npm run backfill:images— read the report. A count of zero, or one far larger than the catalogue, means the path resolution is wrong rather than the images.--applyon QA, then look at the images, including a detail view. This is the only check no assertion covers: whether they still look good enough to sell from.exif: undefinedis the assertion this whole issue exists to be able to make.Closes #226
Verified where it actually has to run rather than only here: the production image builds and `require('sharp')` succeeds inside it on Node v20.20.2, linux/x64, with libvips 8.18.6 and `withExif` available, needing no build toolchain. The architecture question is already settled by this same node:20-bookworm-slim base running in production today, and sharp ships glibc prebuilds for both linux-x64 and linux-arm64, so it adds no constraint that deployment did not already satisfy. Installing it locally found a trap worth recording. sharp requires Node >=20.9.0 and its platform binary is an *optional* dependency, so npm skips it when the engine check fails and still reports success. Installed under this machine's default 18.16.1 the result is a node_modules that looks complete and throws `Could not load the "sharp" module using the win32-x64 runtime` at require time — which reads as a broken package rather than as a wrong Node version. The fix is `npm install --include=optional sharp` under Node 20+, and the prevention is using start-local.ps1 or run-tests.ps1, which switch first. `engines` is now declared so npm at least warns, and the README's existing Node 20 section says what the failure looks like, since the error message names a runtime rather than a version and points nowhere useful. The lockfile carries every platform variant including linux-x64 and linux-arm64, so a build on another platform resolves correctly. The Dockerfile does not copy the lockfile at all and installs fresh, so this matters for contributors rather than for the image. Ref #226