77import express from "express" ;
88import type { Server as SocketIoServer } from "socket.io" ;
99import { 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
1517export 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