Skip to content

Commit 4c1e845

Browse files
OhYeeclaude
andcommitted
fix(storage): drawing-scoped S3 queries; drop now-redundant cross-drawing check
Storage-management portion of the per-drawing storage isolation fix that began in feat/s3-image-upload + refactor/backend-direct-upload. S3File rows are now keyed by (drawingId, fileId) and S3 objects sit under a per-drawing path, so each drawing's storage is independent of every other drawing's. Concretely: - routes/storage.ts: trim/diff/orphans queries are scoped by drawingId via the shared drawingS3Prefix() helper instead of inline startsWith() checks against s3Key. - The findFileIdsStillReferencedElsewhere() helper is gone — it was protecting against cross-drawing collisions on a global PK that no longer exists. Each drawing now owns its own row + object exclusively (duplicates create their own copies in routes/dashboard/drawings.ts). - DELETE /:fileId in the orphans path uses { drawingId_fileId }. Change-Id: I4fd81b44384328a29f5890413586aadbfc96f8d1 Co-developed-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6ab515b commit 4c1e845

1 file changed

Lines changed: 41 additions & 107 deletions

File tree

backend/src/routes/storage.ts

Lines changed: 41 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
import express from "express";
88
import type { Server as SocketIoServer } from "socket.io";
99
import { PrismaClient } from "../generated/client";
10-
import { isS3Enabled, deleteS3Object, listS3Objects } from "../s3";
11-
12-
const FILE_KEY_PREFIX =
13-
process.env.S3_KEY_PREFIX?.replace(/\/+$/, "") || "excalidash";
10+
import {
11+
isS3Enabled,
12+
deleteS3Object,
13+
listS3Objects,
14+
drawingS3Prefix,
15+
} from "../s3";
1416

1517
export type StorageRouteDeps = {
1618
prisma: PrismaClient;
@@ -27,44 +29,6 @@ export type StorageRouteDeps = {
2729
io: SocketIoServer;
2830
};
2931

30-
/**
31-
* Returns the subset of fileIds still referenced by a drawing OTHER than
32-
* `excludeDrawingId` and owned by `userId`. Those files must NOT be
33-
* deleted from S3 / S3File when trimming this drawing.
34-
*
35-
* Uses a substring check on the JSON columns: fileIds are randomly
36-
* generated identifiers (UUID/SHA-1), so false positives are negligible
37-
* compared to the cost of parsing every drawing's full JSON.
38-
*/
39-
const findFileIdsStillReferencedElsewhere = async (
40-
prisma: PrismaClient,
41-
userId: string,
42-
excludeDrawingId: string,
43-
fileIds: string[]
44-
): Promise<Set<string>> => {
45-
const stillReferenced = new Set<string>();
46-
if (fileIds.length === 0) return stillReferenced;
47-
48-
for (const fileId of fileIds) {
49-
// Wrap with quotes so we don't match a longer id that contains this
50-
// one as a substring (`abc123` shouldn't match `abc1234`).
51-
const needle = `"${fileId}"`;
52-
const other = await prisma.drawing.findFirst({
53-
where: {
54-
userId,
55-
id: { not: excludeDrawingId },
56-
OR: [
57-
{ files: { contains: needle } },
58-
{ elements: { contains: needle } },
59-
],
60-
},
61-
select: { id: true },
62-
});
63-
if (other) stillReferenced.add(fileId);
64-
}
65-
return stillReferenced;
66-
};
67-
6832
/**
6933
* Collect fileIds referenced by image elements.
7034
* When includeDeleted is false, elements with isDeleted: true are skipped.
@@ -169,64 +133,44 @@ export const registerStorageRoutes = (
169133
const filesRemoved = originalFileCount - Object.keys(cleanedFiles).length;
170134

171135
// 6. S3 cleanup
136+
//
137+
// S3File is keyed (drawingId, fileId) and S3 objects sit under a
138+
// per-drawing path, so this drawing's storage is independent from
139+
// every other drawing's — no cross-drawing reference check needed.
140+
// Duplicates are made by copying objects into the new drawingId
141+
// path (see drawings.ts /duplicate), so deleting the original
142+
// does not strand a sibling.
172143
let s3ObjectsDeleted = 0;
173144
let s3DeleteErrors = 0;
174145

175146
if (isS3Enabled()) {
176-
const s3Prefix = `${FILE_KEY_PREFIX}/${userId}/${id}/`;
147+
const s3Prefix = drawingS3Prefix(userId, id);
177148

178-
// Query S3File records for this drawing
179149
const s3FileRecords = await prisma.s3File.findMany({
180-
where: { s3Key: { startsWith: s3Prefix } },
150+
where: { drawingId: id },
181151
});
182-
183-
// List actual S3 objects
184152
const s3Objects = await listS3Objects(s3Prefix);
185153

186-
// Collect candidate orphan fileIds, then exclude any that another
187-
// drawing of the same user still references (Excalidraw fileIds are
188-
// content hashes, so the same image can appear in multiple drawings).
189-
const candidateOrphans = new Map<string, Set<string>>(); // fileId -> s3Keys
190-
191-
const recordOrphan = (fileId: string, s3Key: string) => {
192-
if (!candidateOrphans.has(fileId)) {
193-
candidateOrphans.set(fileId, new Set());
194-
}
195-
candidateOrphans.get(fileId)!.add(s3Key);
196-
};
154+
// Union of S3File rows and physical S3 objects, minus the
155+
// surviving fileIds — anything left is orphan storage.
156+
const orphanKeys = new Set<string>();
157+
const orphanFileIds = new Set<string>();
197158

198159
for (const record of s3FileRecords) {
199-
if (!survivingFileIds.has(record.id)) {
200-
recordOrphan(record.id, record.s3Key);
160+
if (!survivingFileIds.has(record.fileId)) {
161+
orphanKeys.add(record.s3Key);
162+
orphanFileIds.add(record.fileId);
201163
}
202164
}
203165

204166
for (const obj of s3Objects) {
205167
const fileId = fileIdFromS3Key(obj.key);
206168
if (fileId && !survivingFileIds.has(fileId)) {
207-
recordOrphan(fileId, obj.key);
208-
}
209-
}
210-
211-
const stillReferenced = await findFileIdsStillReferencedElsewhere(
212-
prisma,
213-
userId,
214-
id,
215-
Array.from(candidateOrphans.keys())
216-
);
217-
218-
const trulyOrphanedKeys = new Set<string>();
219-
const trulyOrphanedRecordIds: string[] = [];
220-
for (const [fileId, keys] of candidateOrphans.entries()) {
221-
if (stillReferenced.has(fileId)) continue;
222-
for (const k of keys) trulyOrphanedKeys.add(k);
223-
if (s3FileRecords.some((r) => r.id === fileId)) {
224-
trulyOrphanedRecordIds.push(fileId);
169+
orphanKeys.add(obj.key);
225170
}
226171
}
227172

228-
// Delete S3 objects.
229-
for (const key of trulyOrphanedKeys) {
173+
for (const key of orphanKeys) {
230174
try {
231175
await deleteS3Object(key);
232176
s3ObjectsDeleted++;
@@ -236,10 +180,9 @@ export const registerStorageRoutes = (
236180
}
237181
}
238182

239-
// Delete S3File rows for the truly-orphaned files.
240-
if (trulyOrphanedRecordIds.length > 0) {
183+
if (orphanFileIds.size > 0) {
241184
await prisma.s3File.deleteMany({
242-
where: { id: { in: trulyOrphanedRecordIds } },
185+
where: { drawingId: id, fileId: { in: Array.from(orphanFileIds) } },
243186
});
244187
}
245188
}
@@ -298,25 +241,25 @@ export const registerStorageRoutes = (
298241
// SQLite file keys
299242
const sqliteFileIds = new Set(Object.keys(files));
300243

301-
// S3File records and actual S3 objects
302-
const s3Prefix = `${FILE_KEY_PREFIX}/${userId}/${id}/`;
244+
// S3File records and actual S3 objects (drawing-scoped)
245+
const s3Prefix = drawingS3Prefix(userId, id);
303246
let s3FileRecords: Array<{
304-
id: string;
247+
fileId: string;
305248
s3Key: string;
306249
mimeType: string;
307250
}> = [];
308251
let s3Objects: Array<{ key: string; size: number }> = [];
309252

310253
if (isS3Enabled()) {
311254
s3FileRecords = await prisma.s3File.findMany({
312-
where: { s3Key: { startsWith: s3Prefix } },
313-
select: { id: true, s3Key: true, mimeType: true },
255+
where: { drawingId: id },
256+
select: { fileId: true, s3Key: true, mimeType: true },
314257
});
315258
s3Objects = await listS3Objects(s3Prefix);
316259
}
317260

318261
const s3RecordMap = new Map(
319-
s3FileRecords.map((r) => [r.id, r])
262+
s3FileRecords.map((r) => [r.fileId, r])
320263
);
321264
const s3ObjectMap = new Map(
322265
s3Objects.map((o) => {
@@ -329,7 +272,7 @@ export const registerStorageRoutes = (
329272
const allFileIds = new Set<string>();
330273
for (const fid of allCanvasRefs) allFileIds.add(fid);
331274
for (const fid of sqliteFileIds) allFileIds.add(fid);
332-
for (const r of s3FileRecords) allFileIds.add(r.id);
275+
for (const r of s3FileRecords) allFileIds.add(r.fileId);
333276
for (const o of s3Objects) {
334277
const fid = fileIdFromS3Key(o.key);
335278
if (fid) allFileIds.add(fid);
@@ -411,33 +354,24 @@ export const registerStorageRoutes = (
411354
let deletedCount = 0;
412355
let errorCount = 0;
413356

414-
// Skip S3-side deletion for fileIds that any other drawing of the
415-
// same user still references — those files must remain reachable.
416-
const stillReferencedElsewhere = await findFileIdsStillReferencedElsewhere(
417-
prisma,
418-
userId,
419-
id,
420-
fileIds as string[]
421-
);
422-
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.
423360
for (const fileId of fileIds as string[]) {
424361
try {
425-
// Delete S3 object via S3File record only when no other drawing
426-
// owned by this user still references the file.
427-
if (isS3Enabled() && !stillReferencedElsewhere.has(fileId)) {
362+
if (isS3Enabled()) {
428363
const s3Record = await prisma.s3File.findUnique({
429-
where: { id: fileId },
364+
where: { drawingId_fileId: { drawingId: id, fileId } },
430365
});
431366
if (s3Record) {
432367
await deleteS3Object(s3Record.s3Key);
433-
await prisma.s3File.delete({ where: { id: fileId } });
368+
await prisma.s3File.delete({
369+
where: { drawingId_fileId: { drawingId: id, fileId } },
370+
});
434371
}
435372
}
436373

437-
// Remove from THIS drawing's files JSON regardless — the
438-
// sibling drawing keeps its own entry pointing at the same key.
439374
delete files[fileId];
440-
441375
deletedCount++;
442376
} catch (err: any) {
443377
console.error(

0 commit comments

Comments
 (0)