@@ -218,12 +218,107 @@ app.use('/upload', requirePin);
218
218
219
219
// Store ongoing uploads
220
220
const uploads = new Map ( ) ;
221
+ // Store folder name mappings for batch uploads with timestamps
222
+ const folderMappings = new Map ( ) ;
223
+ // Store batch IDs for folder uploads
224
+ const batchUploads = new Map ( ) ;
225
+ // Store batch activity timestamps
226
+ const batchActivity = new Map ( ) ;
227
+
228
+ // Add cleanup interval for inactive batches
229
+ setInterval ( ( ) => {
230
+ const now = Date . now ( ) ;
231
+ for ( const [ batchId , lastActivity ] of batchActivity . entries ( ) ) {
232
+ if ( now - lastActivity >= 5 * 60 * 1000 ) { // 5 minutes of inactivity
233
+ // Clean up all folder mappings for this batch
234
+ for ( const key of folderMappings . keys ( ) ) {
235
+ if ( key . endsWith ( `-${ batchId } ` ) ) {
236
+ folderMappings . delete ( key ) ;
237
+ }
238
+ }
239
+ batchActivity . delete ( batchId ) ;
240
+ log . info ( `Cleaned up folder mappings for inactive batch: ${ batchId } ` ) ;
241
+ }
242
+ }
243
+ } , 60000 ) ; // Check every minute
244
+
245
+ // Add these helper functions before the routes
246
+ async function getUniqueFilePath ( filePath ) {
247
+ const dir = path . dirname ( filePath ) ;
248
+ const ext = path . extname ( filePath ) ;
249
+ const baseName = path . basename ( filePath , ext ) ;
250
+ let counter = 1 ;
251
+ let finalPath = filePath ;
252
+
253
+ while ( true ) {
254
+ try {
255
+ // Try to create the file exclusively - will fail if file exists
256
+ const fileHandle = await fs . promises . open ( finalPath , 'wx' ) ;
257
+ // Return both the path and handle instead of closing it
258
+ return { path : finalPath , handle : fileHandle } ;
259
+ } catch ( err ) {
260
+ if ( err . code === 'EEXIST' ) {
261
+ // File exists, try next number
262
+ finalPath = path . join ( dir , `${ baseName } (${ counter } )${ ext } ` ) ;
263
+ counter ++ ;
264
+ } else {
265
+ throw err ; // Other errors should be handled by caller
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ async function getUniqueFolderPath ( folderPath ) {
272
+ let counter = 1 ;
273
+ let finalPath = folderPath ;
274
+
275
+ while ( true ) {
276
+ try {
277
+ // Try to create the directory - mkdir with recursive:false is atomic
278
+ await fs . promises . mkdir ( finalPath , { recursive : false } ) ;
279
+ return finalPath ;
280
+ } catch ( err ) {
281
+ if ( err . code === 'EEXIST' ) {
282
+ // Folder exists, try next number
283
+ finalPath = `${ folderPath } (${ counter } )` ;
284
+ counter ++ ;
285
+ } else if ( err . code === 'ENOENT' ) {
286
+ // Parent directory doesn't exist, create it first
287
+ await fs . promises . mkdir ( path . dirname ( finalPath ) , { recursive : true } ) ;
288
+ // Then try again with the same path
289
+ continue ;
290
+ } else {
291
+ throw err ; // Other errors should be handled by caller
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ // Validate batch ID format
298
+ function isValidBatchId ( batchId ) {
299
+ // Batch ID should be in format: timestamp-randomstring
300
+ return / ^ \d + - [ a - z 0 - 9 ] { 9 } $ / . test ( batchId ) ;
301
+ }
221
302
222
303
// Routes
223
304
app . post ( '/upload/init' , async ( req , res ) => {
224
305
const { filename, fileSize } = req . body ;
306
+ let batchId = req . headers [ 'x-batch-id' ] ;
307
+
308
+ // For single file uploads without a batch ID, generate one
309
+ if ( ! batchId ) {
310
+ const timestamp = Date . now ( ) ;
311
+ const randomStr = crypto . randomBytes ( 4 ) . toString ( 'hex' ) . substring ( 0 , 9 ) ;
312
+ batchId = `${ timestamp } -${ randomStr } ` ;
313
+ } else if ( ! isValidBatchId ( batchId ) ) {
314
+ log . error ( 'Invalid batch ID format' ) ;
315
+ return res . status ( 400 ) . json ( { error : 'Invalid batch ID format' } ) ;
316
+ }
317
+
318
+ // Always update batch activity timestamp for any upload
319
+ batchActivity . set ( batchId , Date . now ( ) ) ;
225
320
226
- const safeFilename = path . normalize ( filename ) . replace ( / ^ ( \. \. ( \/ | \\ | $ ) ) + / , '' )
321
+ const safeFilename = path . normalize ( filename ) . replace ( / ^ ( \. \. ( \/ | \\ | $ ) ) + / , '' ) ;
227
322
228
323
// Check file size limit
229
324
if ( fileSize > maxFileSize ) {
@@ -235,23 +330,72 @@ app.post('/upload/init', async (req, res) => {
235
330
} ) ;
236
331
}
237
332
238
- const uploadId = Date . now ( ) . toString ( ) ;
239
- const filePath = path . join ( uploadDir , safeFilename ) ;
333
+ const uploadId = crypto . randomBytes ( 16 ) . toString ( 'hex' ) ;
334
+ let filePath = path . join ( uploadDir , safeFilename ) ;
335
+ let fileHandle ;
240
336
241
337
try {
242
- await ensureDirectoryExists ( filePath ) ;
338
+ // Handle file/folder duplication
339
+ const pathParts = safeFilename . split ( '/' ) ;
243
340
341
+ if ( pathParts . length > 1 ) {
342
+ // This is a file within a folder
343
+ const originalFolderName = pathParts [ 0 ] ;
344
+ const folderPath = path . join ( uploadDir , originalFolderName ) ;
345
+
346
+ // Check if we already have a mapping for this folder in this batch
347
+ let newFolderName = folderMappings . get ( `${ originalFolderName } -${ batchId } ` ) ;
348
+
349
+ if ( ! newFolderName ) {
350
+ try {
351
+ // Try to create the folder atomically first
352
+ await fs . promises . mkdir ( folderPath , { recursive : false } ) ;
353
+ newFolderName = originalFolderName ;
354
+ } catch ( err ) {
355
+ if ( err . code === 'EEXIST' ) {
356
+ // Folder exists, get a unique name
357
+ const uniqueFolderPath = await getUniqueFolderPath ( folderPath ) ;
358
+ newFolderName = path . basename ( uniqueFolderPath ) ;
359
+ log . info ( `Folder "${ originalFolderName } " exists, using "${ newFolderName } " instead` ) ;
360
+ } else {
361
+ throw err ;
362
+ }
363
+ }
364
+
365
+ folderMappings . set ( `${ originalFolderName } -${ batchId } ` , newFolderName ) ;
366
+ }
367
+
368
+ // Replace the original folder path with the mapped one and keep original file name
369
+ pathParts [ 0 ] = newFolderName ;
370
+ filePath = path . join ( uploadDir , ...pathParts ) ;
371
+
372
+ // Ensure parent directories exist
373
+ await fs . promises . mkdir ( path . dirname ( filePath ) , { recursive : true } ) ;
374
+ }
375
+
376
+ // For both single files and files in folders, get a unique path and file handle
377
+ const result = await getUniqueFilePath ( filePath ) ;
378
+ filePath = result . path ;
379
+ fileHandle = result . handle ;
380
+
381
+ // Create upload entry (using the file handle we already have)
244
382
uploads . set ( uploadId , {
245
- safeFilename,
383
+ safeFilename : path . relative ( uploadDir , filePath ) ,
246
384
filePath,
247
385
fileSize,
248
386
bytesReceived : 0 ,
249
- writeStream : fs . createWriteStream ( filePath )
387
+ writeStream : fileHandle . createWriteStream ( )
250
388
} ) ;
251
389
252
- log . info ( `Initialized upload for ${ safeFilename } (${ fileSize } bytes)` ) ;
390
+ log . info ( `Initialized upload for ${ path . relative ( uploadDir , filePath ) } (${ fileSize } bytes)` ) ;
253
391
res . json ( { uploadId } ) ;
254
392
} catch ( err ) {
393
+ // Clean up file handle if something went wrong
394
+ if ( fileHandle ) {
395
+ await fileHandle . close ( ) . catch ( ( ) => { } ) ;
396
+ // Try to remove the file if it was created
397
+ fs . unlink ( filePath ) . catch ( ( ) => { } ) ;
398
+ }
255
399
log . error ( `Failed to initialize upload: ${ err . message } ` ) ;
256
400
res . status ( 500 ) . json ( { error : 'Failed to initialize upload' } ) ;
257
401
}
@@ -270,6 +414,13 @@ app.post('/upload/chunk/:uploadId', express.raw({
270
414
}
271
415
272
416
try {
417
+ // Get the batch ID from the request headers
418
+ const batchId = req . headers [ 'x-batch-id' ] ;
419
+ if ( batchId && isValidBatchId ( batchId ) ) {
420
+ // Update batch activity timestamp
421
+ batchActivity . set ( batchId , Date . now ( ) ) ;
422
+ }
423
+
273
424
upload . writeStream . write ( Buffer . from ( req . body ) ) ;
274
425
upload . bytesReceived += chunkSize ;
275
426
0 commit comments