feat(uploads): strip metadata from every accepted upload (#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
This commit is contained in:
2026-08-29 11:54:34 -05:00
parent e85be0f970
commit aecccef418
2 changed files with 158 additions and 0 deletions
@@ -0,0 +1,119 @@
import request from 'supertest';
import sharp from 'sharp';
import { promises as fs } from 'fs';
import path from 'path';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
const UPLOADS_DIR = process.env.UPLOADS_DIR as string;
beforeAll(async () => {
await fs.mkdir(UPLOADS_DIR, { recursive: true });
});
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
/**
* A JPEG carrying GPS EXIF, built rather than committed as a binary fixture so
* what it contains is readable in this file. This is the exact shape of the
* problem: a photograph that says where it was taken.
*/
async function photoWithLocation(): Promise<Buffer> {
return sharp({
create: { width: 3000, height: 2000, channels: 3, background: { r: 120, g: 90, b: 60 } }
})
// GPS tags live in IFD3 — that is the GPS IFD as libvips names it, and
// sharp's Exif type has no separate `GPS` key. Writing them into IFD0
// instead would still produce EXIF, but not the tags this issue is
// actually about.
.withExif({
IFD0: { Make: 'TestCam', Model: 'X1' },
IFD3: { GPSLatitudeRef: 'N', GPSLatitude: '51/1 30/1 0/1', GPSLongitudeRef: 'W' }
})
.jpeg()
.toBuffer();
}
async function storedPathFor(itemId: number): Promise<string> {
const { rows } = await pool.query<{ image_path: string }>(
`SELECT image_path FROM item_images WHERE item_id = $1 ORDER BY sort_order`,
[itemId]
);
const row = rows[0];
if (!row) throw new Error(`no item_images row for item ${itemId}`);
// image_path is '/uploads/<name>'; the file is that name inside UPLOADS_DIR.
return path.join(UPLOADS_DIR, path.basename(row.image_path));
}
async function createItemWith(image: Buffer, filename: string): Promise<number> {
const res = await request(app)
.post('/api/admin/items')
.field('name', 'Vase')
.field('description', '')
.field('price', '40')
.attach('images', image, filename);
expect(res.status).toBe(200);
return res.body.id;
}
describe('an uploaded photo does not keep where it was taken', () => {
it('has no EXIF once stored', async () => {
const withGps = await photoWithLocation();
// Guard the fixture itself: if this ever stops carrying EXIF, the
// assertion below would pass while testing nothing at all.
expect((await sharp(withGps).metadata()).exif).toBeDefined();
const itemId = await createItemWith(withGps, 'vase.jpg');
const stored = await sharp(await storedPathFor(itemId)).metadata();
expect(stored.exif).toBeUndefined();
});
it('is bounded to the maximum dimension', async () => {
const itemId = await createItemWith(await photoWithLocation(), 'vase.jpg');
const stored = await sharp(await storedPathFor(itemId)).metadata();
expect(stored.width).toBe(2000);
expect(stored.height).toBe(1333);
});
it('keeps the format, so the stored extension still describes the file', async () => {
const itemId = await createItemWith(await photoWithLocation(), 'vase.jpg');
const storedPath = await storedPathFor(itemId);
expect(path.extname(storedPath)).toBe('.jpg');
expect((await sharp(storedPath).metadata()).format).toBe('jpeg');
});
it('does not enlarge an image that is already small', async () => {
const small = await sharp({
create: { width: 300, height: 200, channels: 3, background: { r: 1, g: 2, b: 3 } }
})
.png()
.toBuffer();
const itemId = await createItemWith(small, 'tiny.png');
const stored = await sharp(await storedPathFor(itemId)).metadata();
expect(stored.width).toBe(300);
expect(stored.height).toBe(200);
});
it('leaves no temporary re-encoding files on the volume', async () => {
await createItemWith(await photoWithLocation(), 'vase.jpg');
const leftovers = (await fs.readdir(UPLOADS_DIR)).filter((name) =>
name.endsWith('.reencoding')
);
expect(leftovers).toEqual([]);
});
});