Skip to content

Commit 98e407e

Browse files
OhYeeclaude
andcommitted
fix(storage): validate fileIds, batch deletions, correct test comment
Three review fixes on the orphan-delete path: - Validate every entry in the request's `fileIds` array against the same `^[\w-]{1,200}$` regex used elsewhere. Without this, a non- string or path-traversal-shaped id slipped past the array-shape check and would only blow up inside the Prisma / S3 calls below. - Batch the cleanup. The previous loop did per-file `s3File.findUnique` + `deleteS3Object` + `s3File.delete` serially. For large selections that's N+1 round trips and a stalled HTTP request. Use a single `findMany` keyed by the fileId set, parallel S3 deletes (concurrency 8) with `Promise.allSettled` so one S3 failure doesn't drop subsequent deletions, and a single `deleteMany` for the rows. - The diff test's `totalCanvasRefs: 2` came with the comment "active only", but the route counts every canvas-referenced fileId including soft-deleted ones (`file-a` active + `file-b` deleted). Fix the comment so it documents the real semantic. Change-Id: Id61e9a2d6f17ccc6e6c8922e6b8e6549b32c73f4 Co-developed-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0013d80 commit 98e407e

2 files changed

Lines changed: 67 additions & 32 deletions

File tree

backend/src/__tests__/storage.integration.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,8 +321,12 @@ describe("Storage management routes", () => {
321321
.set("Authorization", `Bearer ${ownerToken}`);
322322

323323
expect(res.status).toBe(200);
324+
// totalCanvasRefs counts every canvas-referenced fileId (active +
325+
// soft-deleted), so file-a (active) and file-b (deleted) both
326+
// contribute. activeCanvasRefs in the per-row payload below is
327+
// what restricts to non-deleted refs.
324328
expect(res.body.summary).toMatchObject({
325-
totalCanvasRefs: 2, // active only
329+
totalCanvasRefs: 2,
326330
totalSqliteFiles: 3,
327331
});
328332

backend/src/routes/storage.ts

Lines changed: 62 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -317,12 +317,28 @@ export const registerStorageRoutes = (
317317
if (!userId) return res.status(401).json({ error: "Unauthorized" });
318318

319319
const { id } = req.params;
320-
const { confirmName, fileIds } = req.body ?? {};
320+
const { confirmName, fileIds: rawFileIds } = req.body ?? {};
321321

322-
if (!Array.isArray(fileIds) || fileIds.length === 0) {
322+
if (!Array.isArray(rawFileIds) || rawFileIds.length === 0) {
323323
return res.status(400).json({ error: "fileIds must be a non-empty array" });
324324
}
325325

326+
// Validate every entry: same regex as the rest of the codebase
327+
// (security.ts sanitiser, /files/:fileId route, processFilesForS3).
328+
// Without this, a non-string or path-traversal-shaped id would
329+
// explode inside the Prisma / S3 calls below.
330+
const VALID_FILE_ID = /^[\w-]{1,200}$/;
331+
const invalidIds = rawFileIds.filter(
332+
(fid) => typeof fid !== "string" || !VALID_FILE_ID.test(fid),
333+
);
334+
if (invalidIds.length > 0) {
335+
return res.status(400).json({
336+
error: "fileIds contains invalid entries",
337+
invalidFileIds: invalidIds,
338+
});
339+
}
340+
const fileIds = rawFileIds as string[];
341+
326342
const drawing = await prisma.drawing.findFirst({
327343
where: { id, userId },
328344
});
@@ -341,46 +357,61 @@ export const registerStorageRoutes = (
341357

342358
// Safety: reject if any fileId is still referenced by a non-deleted element
343359
const activeRefs = collectReferencedFileIds(elements, false);
344-
const blockedIds = (fileIds as string[]).filter((fid) =>
345-
activeRefs.has(fid)
346-
);
360+
const blockedIds = fileIds.filter((fid) => activeRefs.has(fid));
347361
if (blockedIds.length > 0) {
348362
return res.status(400).json({
349363
error: "Cannot delete files referenced by active elements",
350364
blockedFileIds: blockedIds,
351365
});
352366
}
353367

354-
let deletedCount = 0;
355-
let errorCount = 0;
356-
357-
// S3File rows are scoped (drawingId, fileId), and each drawing
358-
// has its own S3 object under its own prefix path — deleting
359-
// here cannot strand a sibling drawing.
360-
for (const fileId of fileIds as string[]) {
361-
try {
362-
if (isS3Enabled()) {
363-
const s3Record = await prisma.s3File.findUnique({
364-
where: { drawingId_fileId: { drawingId: id, fileId } },
365-
});
366-
if (s3Record) {
367-
await deleteS3Object(s3Record.s3Key);
368-
await prisma.s3File.delete({
369-
where: { drawingId_fileId: { drawingId: id, fileId } },
370-
});
371-
}
372-
}
368+
// Batched S3 + DB cleanup. S3File rows are scoped
369+
// (drawingId, fileId), and each drawing has its own S3 object
370+
// under its own prefix path — deletion here cannot strand a
371+
// sibling drawing. Doing N+1 sequential lookups + deletes per
372+
// file would tie up the request unnecessarily for large
373+
// selections.
374+
let s3ObjectsDeleted = 0;
375+
let s3DeleteErrors = 0;
373376

374-
delete files[fileId];
375-
deletedCount++;
376-
} catch (err: any) {
377-
console.error(
378-
`[storage/orphans] Failed to delete fileId=${fileId}`,
379-
err
377+
if (isS3Enabled()) {
378+
const s3Records = await prisma.s3File.findMany({
379+
where: { drawingId: id, fileId: { in: fileIds } },
380+
});
381+
382+
const S3_DELETE_CONCURRENCY = 8;
383+
for (let i = 0; i < s3Records.length; i += S3_DELETE_CONCURRENCY) {
384+
const batch = s3Records.slice(i, i + S3_DELETE_CONCURRENCY);
385+
const results = await Promise.allSettled(
386+
batch.map((rec) => deleteS3Object(rec.s3Key)),
380387
);
381-
errorCount++;
388+
for (let j = 0; j < results.length; j++) {
389+
const result = results[j];
390+
if (result.status === "fulfilled") {
391+
s3ObjectsDeleted++;
392+
} else {
393+
console.error(
394+
`[storage/orphans] Failed to delete S3 object: ${batch[j].s3Key}`,
395+
result.reason,
396+
);
397+
s3DeleteErrors++;
398+
}
399+
}
382400
}
401+
402+
await prisma.s3File.deleteMany({
403+
where: { drawingId: id, fileId: { in: fileIds } },
404+
});
405+
}
406+
407+
// Update the drawing's files JSON regardless of S3 outcomes —
408+
// the JSON entry is what the editor reads, and once trimmed it
409+
// can't be restored from the bucket alone.
410+
for (const fileId of fileIds) {
411+
delete files[fileId];
383412
}
413+
const deletedCount = fileIds.length;
414+
const errorCount = s3DeleteErrors;
384415

385416
// Also remove deleted elements that reference the orphaned files,
386417
// so the files disappear from the diff completely.

0 commit comments

Comments
 (0)