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:
@@ -17,6 +17,7 @@ import {
|
||||
signatureMatches
|
||||
} from '../uploadTypes';
|
||||
import { notifyFavoritersOfSale, notifyFavoritersOfRemoval, collectFavoriteRecipients } from '../favoriteAlerts';
|
||||
import { reencodeInPlace } from '../imageProcessing';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -178,6 +179,34 @@ async function verifyUploadedImages(req: Request): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds every accepted file so it carries no metadata (#226).
|
||||
*
|
||||
* After verification, deliberately: re-encoding a file whose bytes do not match
|
||||
* its declared type would be doing work on something already refused, and
|
||||
* sharp's own error would replace the clearer message that check produces.
|
||||
*
|
||||
* A failure here refuses the upload rather than storing the original. Storing
|
||||
* it would mean the one case where a photo keeps the coordinates it was taken
|
||||
* at is the case nobody was told about.
|
||||
*
|
||||
* Returns the message to refuse with, or null when every file was rebuilt.
|
||||
*/
|
||||
async function stripUploadedImages(req: Request): Promise<string | null> {
|
||||
const files = (req.files as Express.Multer.File[]) || [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
await reencodeInPlace(file.path, file.mimetype);
|
||||
} catch (err) {
|
||||
console.error(`[upload] could not re-encode ${file.path}:`, err);
|
||||
return `${file.originalname} could not be processed`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// No error-handling middleware is mounted on the app, so translate multer's
|
||||
// limit errors here instead of letting them surface as a generic 500.
|
||||
const uploadImages = (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -200,6 +229,16 @@ const uploadImages = (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
verifyUploadedImages(req)
|
||||
.then((problem) => {
|
||||
if (problem) {
|
||||
res.status(400).json({ error: problem });
|
||||
return null;
|
||||
}
|
||||
return stripUploadedImages(req);
|
||||
})
|
||||
.then((problem) => {
|
||||
// The first stage returns null both when it answered and when it found
|
||||
// nothing wrong, so the response itself is what distinguishes them.
|
||||
if (res.headersSent) return;
|
||||
if (problem) {
|
||||
res.status(400).json({ error: problem });
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user