Skip to content

Commit 38fc599

Browse files
authored
Merge pull request #20 from greirson/feat-dupe-handling
feat: Duplicate file/folder handling
2 parents b01f46c + bcc5559 commit 38fc599

File tree

2 files changed

+196
-12
lines changed

2 files changed

+196
-12
lines changed

public/index.html

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,15 @@ <h1>{{SITE_TITLE}}</h1>
6161
const MAX_RETRIES = 3;
6262
const RETRY_DELAY = 1000;
6363

64+
// Utility function to generate a unique batch ID
65+
function generateBatchId() {
66+
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
67+
}
68+
6469
class FileUploader {
65-
constructor(file) {
70+
constructor(file, batchId) {
6671
this.file = file;
72+
this.batchId = batchId;
6773
this.uploadId = null;
6874
this.position = 0;
6975
this.progressElement = null;
@@ -88,7 +94,10 @@ <h1>{{SITE_TITLE}}</h1>
8894

8995
const response = await fetch('/upload/init', {
9096
method: 'POST',
91-
headers: { 'Content-Type': 'application/json' },
97+
headers: {
98+
'Content-Type': 'application/json',
99+
'X-Batch-ID': this.batchId
100+
},
92101
body: JSON.stringify({
93102
filename: uploadPath,
94103
fileSize: this.file.size
@@ -207,10 +216,16 @@ <h1>{{SITE_TITLE}}</h1>
207216
name: folderName,
208217
isFolder: true,
209218
totalSize: 0,
210-
files: []
219+
files: [],
220+
// Use the first file's batch ID or generate a new one
221+
batchId: file.batchId
211222
});
212223
}
213224
const group = groups.get(folderName);
225+
// If group doesn't have a batch ID yet, use the file's batch ID
226+
if (!group.batchId) {
227+
group.batchId = file.batchId;
228+
}
214229
group.files.push(file);
215230
group.totalSize += file.size;
216231
} else {
@@ -219,7 +234,8 @@ <h1>{{SITE_TITLE}}</h1>
219234
name: file.name,
220235
isFolder: false,
221236
totalSize: file.size,
222-
files: [file]
237+
files: [file],
238+
batchId: file.batchId
223239
});
224240
}
225241
});
@@ -230,11 +246,13 @@ <h1>{{SITE_TITLE}}</h1>
230246
// Helper function to process directory entries
231247
async function getAllFileEntries(dataTransferItems) {
232248
let fileEntries = [];
249+
const batchId = generateBatchId();
233250

234251
async function traverseEntry(entry, path = '') {
235252
if (entry.isFile) {
236253
const file = await new Promise((resolve) => entry.file(resolve));
237254
file.relativePath = path;
255+
file.batchId = batchId; // Use the same batch ID for all files in this drop
238256
fileEntries.push(file);
239257
} else if (entry.isDirectory) {
240258
const reader = entry.createReader();
@@ -293,30 +311,41 @@ <h1>{{SITE_TITLE}}</h1>
293311
function handleDrop(e) {
294312
const items = e.dataTransfer.items;
295313
if (items && items[0].webkitGetAsEntry) {
314+
// Handle folder/file drop using DataTransferItemList
296315
getAllFileEntries(items).then(newFiles => {
297316
files = newFiles;
298317
updateFileList();
299318
});
300319
} else {
320+
// Handle single file drop
321+
const batchId = generateBatchId();
301322
files = [...e.dataTransfer.files];
323+
files.forEach(file => {
324+
file.relativePath = ''; // No relative path for dropped files
325+
file.batchId = batchId;
326+
});
302327
updateFileList();
303328
}
304329
}
305330

306331
function handleFiles(e) {
332+
const batchId = generateBatchId();
307333
files = [...e.target.files];
308334
files.forEach(file => {
309335
file.relativePath = ''; // No relative path for individual files
336+
file.batchId = batchId;
310337
});
311338
updateFileList();
312339
}
313340

314341
function handleFolders(e) {
342+
const batchId = generateBatchId();
315343
files = [...e.target.files];
316344
files.forEach(file => {
317345
const pathParts = file.webkitRelativePath.split('/');
318346
pathParts.pop(); // Remove filename
319347
file.relativePath = pathParts.length > 0 ? pathParts.join('/') + '/' : '';
348+
file.batchId = batchId;
320349
});
321350
updateFileList();
322351
}
@@ -354,11 +383,15 @@ <h1>{{SITE_TITLE}}</h1>
354383
document.getElementById('uploadProgress').innerHTML = '';
355384

356385
const groupedItems = groupFilesByFolder(files);
386+
357387
const results = await Promise.all(
358388
groupedItems.map(async item => {
359389
let success = true;
390+
// Use the group's batch ID for all files in the group
391+
const groupBatchId = item.batchId || generateBatchId();
360392
for (const file of item.files) {
361-
const uploader = new FileUploader(file);
393+
// Always use the group's batch ID
394+
const uploader = new FileUploader(file, groupBatchId);
362395
if (!await uploader.start()) {
363396
success = false;
364397
}

server.js

Lines changed: 158 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,107 @@ app.use('/upload', requirePin);
218218

219219
// Store ongoing uploads
220220
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-z0-9]{9}$/.test(batchId);
301+
}
221302

222303
// Routes
223304
app.post('/upload/init', async (req, res) => {
224305
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());
225320

226-
const safeFilename = path.normalize(filename).replace(/^(\.\.(\/|\\|$))+/, '')
321+
const safeFilename = path.normalize(filename).replace(/^(\.\.(\/|\\|$))+/, '');
227322

228323
// Check file size limit
229324
if (fileSize > maxFileSize) {
@@ -235,23 +330,72 @@ app.post('/upload/init', async (req, res) => {
235330
});
236331
}
237332

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;
240336

241337
try {
242-
await ensureDirectoryExists(filePath);
338+
// Handle file/folder duplication
339+
const pathParts = safeFilename.split('/');
243340

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)
244382
uploads.set(uploadId, {
245-
safeFilename,
383+
safeFilename: path.relative(uploadDir, filePath),
246384
filePath,
247385
fileSize,
248386
bytesReceived: 0,
249-
writeStream: fs.createWriteStream(filePath)
387+
writeStream: fileHandle.createWriteStream()
250388
});
251389

252-
log.info(`Initialized upload for ${safeFilename} (${fileSize} bytes)`);
390+
log.info(`Initialized upload for ${path.relative(uploadDir, filePath)} (${fileSize} bytes)`);
253391
res.json({ uploadId });
254392
} 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+
}
255399
log.error(`Failed to initialize upload: ${err.message}`);
256400
res.status(500).json({ error: 'Failed to initialize upload' });
257401
}
@@ -270,6 +414,13 @@ app.post('/upload/chunk/:uploadId', express.raw({
270414
}
271415

272416
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+
273424
upload.writeStream.write(Buffer.from(req.body));
274425
upload.bytesReceived += chunkSize;
275426

0 commit comments

Comments
 (0)