mbkbucket 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,84 +1,42 @@
1
- import express from "express";
2
- import { validateSessionAndRole, } from "mbkauthe";
3
- import { listfiles, uploadFile, deleteFile, deleteFiles, deleteFolder, downloadFile, getFileMetadata, fileExists, ensureKeyHasAppPrefix, ensurePrefix, getAppName, createMultipartUpload, uploadPart, completeMultipartUpload, abortMultipartUpload, listIncompleteMultipartUploads, cleanupIncompleteMultipartUploads } from "../s3.js";
4
- import multer from "multer";
5
- import { createLogger } from "../debug.js";
1
+ import { pipeline } from "stream/promises";
2
+ import {
3
+ listfiles, uploadFile, deleteFile, deleteFiles, deleteFolder,
4
+ downloadFile, getFileMetadata, fileExists,
5
+ ensureKeyHasAppPrefix, ensurePrefix, getAppName,
6
+ createMultipartUpload, uploadPart, completeMultipartUpload,
7
+ abortMultipartUpload, listIncompleteMultipartUploads,
8
+ cleanupIncompleteMultipartUploads
9
+ } from "../services/s3.service.js";
10
+ import { sendApiError, classifyApiError } from "../utils/errors.js";
11
+ import { getBaseName } from "../utils/helpers.js";
12
+ import { createLogger } from "../utils/logger.js";
6
13
 
7
- const router = express.Router();
8
14
  const debugApi = createLogger('api');
9
15
 
10
- function classifyApiError(error, fallbackMessage = 'Request failed') {
11
- const message = String(error?.message || fallbackMessage);
12
- const lower = message.toLowerCase();
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
13
19
 
14
- if (lower.includes('bucketconnection environment variable is not set')) {
15
- return { status: 503, code: 'BUCKET_CONFIG_ERROR', message };
16
- }
17
- if (lower.includes('no bucket selected') || lower.includes('not found in bucketconnection')) {
18
- return { status: 400, code: 'INVALID_BUCKET', message };
19
- }
20
- if (lower.includes('already exists')) {
21
- return { status: 409, code: 'CONFLICT', message };
22
- }
23
- if (lower.includes('not found')) {
24
- return { status: 404, code: 'NOT_FOUND', message };
25
- }
26
- if (lower.includes('access denied')) {
27
- return { status: 403, code: 'ACCESS_DENIED', message };
28
- }
29
- if (lower.includes('required') || lower.includes('invalid') || lower.includes('must be')) {
30
- return { status: 400, code: 'VALIDATION_ERROR', message };
20
+ /**
21
+ * Resolve & validate a prefix from request input. Returns the effective prefix
22
+ * or sends a 400 JSON error response (and returns null).
23
+ */
24
+ function resolvePrefix(req, res, prefix) {
25
+ try {
26
+ return ensurePrefix(prefix);
27
+ } catch (e) {
28
+ res.status(400).json({ success: false, error: e.message });
29
+ return null;
31
30
  }
32
- return { status: 500, code: 'INTERNAL_ERROR', message };
33
31
  }
34
32
 
35
- function sendApiError(res, error, fallbackMessage = 'Request failed') {
36
- const mapped = classifyApiError(error, fallbackMessage);
37
- return res.status(mapped.status).json({ success: false, code: mapped.code, error: mapped.message });
33
+ /**
34
+ * Build a full S3 key from a prefix and filename, stripping trailing slashes.
35
+ */
36
+ function buildKey(prefix, fileName) {
37
+ return `${prefix.replace(/\/+$/, '')}/${fileName}`;
38
38
  }
39
39
 
40
- function getBaseName(path = '') {
41
- const value = String(path || '');
42
- const lastSlashIndex = value.lastIndexOf('/');
43
- return lastSlashIndex === -1 ? value : value.substring(lastSlashIndex + 1);
44
- }
45
-
46
- // Central bucket guard for all API routes under /mbkbucket
47
- router.use((req, res, next) => {
48
- if (req.bucketResolveError) {
49
- return sendApiError(res, req.bucketResolveError, 'Invalid bucket selection');
50
- }
51
- if (!req.activeBucket) {
52
- return sendApiError(res, new Error('No bucket selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.'));
53
- }
54
- next();
55
- });
56
-
57
- // This is the maximum size we allow per file part. The dashboard uses the same
58
- // size as its single-upload threshold and multipart chunk size.
59
- const MAX_CHUNK_SIZE = 50 * 1024 * 1024; // 50 MB
60
-
61
- const upload = multer({
62
- storage: multer.memoryStorage(),
63
- limits: { fileSize: MAX_CHUNK_SIZE }
64
- });
65
-
66
- const uploadChunk = (req, res, next) => {
67
- upload.single('chunk')(req, res, (err) => {
68
- if (err) {
69
- // Multer throws an error if the file is too large (or another multipart parsing issue occurs)
70
- if (err.code === 'LIMIT_FILE_SIZE') {
71
- return res.status(413).json({
72
- success: false,
73
- error: `Chunk too large (max ${Math.round(MAX_CHUNK_SIZE / (1024 * 1024))} MB).`,
74
- });
75
- }
76
- return next(err);
77
- }
78
- next();
79
- });
80
- };
81
-
82
40
  async function cleanupFolderMarker(folderPath, bucketName) {
83
41
  if (!folderPath) return;
84
42
 
@@ -93,13 +51,15 @@ async function cleanupFolderMarker(folderPath, bucketName) {
93
51
  }
94
52
  }
95
53
 
96
- // API endpoint for multipart upload cleanup
97
- router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async (req, res) => {
54
+ // ---------------------------------------------------------------------------
55
+ // Controller handlers
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export async function cleanupUploads(req, res) {
98
59
  try {
99
60
  const { olderThanDays = 7, prefix = '' } = req.body;
100
61
  const bucketName = req.activeBucket;
101
62
 
102
- // Validate olderThanDays
103
63
  const days = parseInt(olderThanDays, 10);
104
64
  if (isNaN(days) || days < 1) {
105
65
  return res.status(400).json({ success: false, error: 'olderThanDays must be a positive integer' });
@@ -117,10 +77,9 @@ router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async
117
77
  console.error('[mbkbucket] Cleanup failed:', error);
118
78
  return sendApiError(res, error, 'Cleanup failed');
119
79
  }
120
- });
80
+ }
121
81
 
122
- // API endpoint to list incomplete multipart uploads
123
- router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), async (req, res) => {
82
+ export async function listIncompleteUploads(req, res) {
124
83
  try {
125
84
  const { prefix = '' } = req.query;
126
85
  const bucketName = req.activeBucket;
@@ -140,71 +99,52 @@ router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), asyn
140
99
  console.error('[mbkbucket] Failed to list incomplete uploads:', error);
141
100
  return sendApiError(res, error, 'Failed to list incomplete uploads');
142
101
  }
143
- });
102
+ }
144
103
 
145
- // API endpoint for fetching files
146
- router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res) => {
104
+ export async function listFiles(req, res) {
147
105
  const { prefix = '', page = '1', search = '', recursive = 'true', token = '' } = req.query;
148
106
  const pageSize = 100;
149
107
  const bucketName = req.activeBucket;
150
- let effectivePrefix;
151
- try {
152
- effectivePrefix = ensurePrefix(prefix);
153
- } catch (e) {
154
- return res.status(400).json({ success: false, error: e.message });
155
- }
156
108
 
157
- // Determine mode: Optimized (Folder View, No Search) vs Legacy/Deep (Flat View or Search)
109
+ const effectivePrefix = resolvePrefix(req, res, prefix);
110
+ if (effectivePrefix === null) return;
111
+
158
112
  const isSearch = search && search.trim().length > 0;
159
- // If recursive is explicitly 'false', we respect it. Default to true if missing (though frontend should send it)
160
- // Logic: Optimization works only if NOT recursive and NOT searching.
161
113
  const isRecursive = recursive === 'true';
162
114
  const useOptimizedListing = !isSearch && !isRecursive;
163
115
 
164
116
  try {
165
117
  let files = [];
166
- let folders = []; // For CommonPrefixes (subfolders)
118
+ let folders = [];
167
119
  let nextContinuationToken = null;
168
120
  let usedPrefix = effectivePrefix;
169
121
  let legacyFallback = false;
170
122
 
171
123
  if (useOptimizedListing) {
172
- // --- OPTIMIZED PATH: S3 Delimiter (Folder View) ---
173
- // We process only one page from S3. This reduces API calls significantly.
174
-
175
- // FIX: Ensure root listing uses trailing slash to prevent "app-name" matching "app-name-backup"
176
- // and ensure directory browsing works correctly if ensurePrefix didn't append slash (which it now does if present in input).
177
- // But for root, input is empty, ensurePrefix returns "app", so we force "app/" here.
178
124
  let listPrefix = effectivePrefix;
179
125
  if (listPrefix === getAppName() && !listPrefix.endsWith('/')) {
180
126
  listPrefix += '/';
181
127
  }
182
128
 
183
129
  const result = await listfiles(listPrefix, {
184
- continuationToken: token || undefined, // Use token from query if provided
130
+ continuationToken: token || undefined,
185
131
  delimiter: '/',
186
- maxKeys: 1000, // Fetch a healthy batch.
132
+ maxKeys: 1000,
187
133
  bucketName
188
134
  });
189
135
 
190
- // Filter out folder marker objects (zero-byte files ending with /)
191
136
  files = (result.Contents || []).filter(f => !f.Key.endsWith('/'));
192
- // S3 returns CommonPrefixes for folders when delimiter is used
193
137
  folders = (result.CommonPrefixes || [])
194
138
  .filter(p => p.Prefix !== effectivePrefix)
195
139
  .map(p => p.Prefix);
196
140
  nextContinuationToken = result.NextContinuationToken;
197
141
 
198
- // Note: In optimized mode, totalFiles is unknown without scanning everything.
199
142
  } else {
200
- // --- LEGACY PATH: Recursive Scan (Search or Flat View) ---
201
143
  let continuationToken = undefined;
202
144
 
203
- // Fetch all files recursively (handling S3 pagination)
204
145
  do {
205
146
  const result = await listfiles(effectivePrefix, { continuationToken, bucketName });
206
147
  if (result.Contents?.length) {
207
- // Filter out folder marker objects (zero-byte files ending with /)
208
148
  for (const file of result.Contents) {
209
149
  if (!file.Key.endsWith('/')) files.push(file);
210
150
  }
@@ -212,7 +152,6 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
212
152
  continuationToken = result.nextToken;
213
153
  } while (continuationToken);
214
154
 
215
- // If no files found under the app prefixed path, try root as a legacy fallback
216
155
  if (!files.length && !search) {
217
156
  try {
218
157
  const rootResult = await listfiles('', { bucketName });
@@ -226,34 +165,29 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
226
165
  }
227
166
  }
228
167
 
229
- // Apply search filter if provided
230
168
  if (isSearch) {
231
169
  const searchLower = search.trim().toLowerCase();
232
170
  files = files.filter(file => file.Key.toLowerCase().includes(searchLower));
233
171
  }
234
172
  }
235
173
 
236
- // Response Construction
237
174
  if (useOptimizedListing) {
238
175
  res.json({
239
176
  success: true,
240
- files: files,
241
- folders: folders,
177
+ files,
178
+ folders,
242
179
  prefix: effectivePrefix,
243
- search: search,
244
- // In optimized mode, we use token-based pagination, not page numbers
180
+ search,
245
181
  currentPage: 1,
246
- totalPages: 1, // Always 1 since we use continuation tokens instead
182
+ totalPages: 1,
247
183
  hasNextPage: !!nextContinuationToken,
248
- hasPrevPage: false, // Token-based pagination doesn't support backwards navigation
249
- nextContinuationToken: nextContinuationToken,
184
+ hasPrevPage: false,
185
+ nextContinuationToken,
250
186
  mode: 'optimized',
251
- totalFiles: -1, // Unknown in optimized mode without full scan
252
- paginationType: 'continuation-token' // Indicate the type of pagination
187
+ totalFiles: -1,
188
+ paginationType: 'continuation-token'
253
189
  });
254
-
255
190
  } else {
256
- // Legacy Pagination Logic (In-Memory)
257
191
  const totalFiles = files.length;
258
192
  const totalPages = Math.max(1, Math.ceil(totalFiles / pageSize));
259
193
  const currentPage = Math.max(1, parseInt(page, 10) || 1);
@@ -263,10 +197,10 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
263
197
  res.json({
264
198
  success: true,
265
199
  files: paginatedFiles,
266
- folders: [], // In recursive mode, frontend calculates folders from file paths
200
+ folders: [],
267
201
  prefix: effectivePrefix,
268
- search: search,
269
- currentPage: currentPage,
202
+ search,
203
+ currentPage,
270
204
  totalPages,
271
205
  hasNextPage: currentPage < totalPages,
272
206
  hasPrevPage: currentPage > 1,
@@ -287,10 +221,9 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
287
221
  totalFiles: 0
288
222
  });
289
223
  }
290
- });
224
+ }
291
225
 
292
- // Standard single-file upload
293
- router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file'), async (req, res) => {
226
+ export async function uploadSingleFile(req, res) {
294
227
  try {
295
228
  const bucketName = req.activeBucket;
296
229
  if (!req.file) {
@@ -298,14 +231,10 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
298
231
  }
299
232
 
300
233
  const { prefix = '' } = req.body;
301
- let effectivePrefix;
302
- try {
303
- effectivePrefix = ensurePrefix(prefix);
304
- } catch (e) {
305
- return res.status(400).json({ success: false, error: e.message });
306
- }
234
+ const effectivePrefix = resolvePrefix(req, res, prefix);
235
+ if (effectivePrefix === null) return;
307
236
 
308
- const key = `${effectivePrefix.replace(/\/+$/, '')}/${req.file.originalname}`;
237
+ const key = buildKey(effectivePrefix, req.file.originalname);
309
238
 
310
239
  const uploadOptions = {
311
240
  metadata: {
@@ -314,19 +243,17 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
314
243
  'user-agent': req.headers['user-agent'] || 'unknown'
315
244
  },
316
245
  bucketName,
317
- preventOverwrite: true // Use atomic operation to prevent race conditions
246
+ preventOverwrite: true
318
247
  };
319
248
 
320
249
  try {
321
250
  await uploadFile(key, req.file.buffer, req.file.mimetype, uploadOptions);
322
251
 
323
- // Clean up folder marker if it exists in the parent directory.
324
252
  const folderPath = key.substring(0, key.lastIndexOf('/') + 1);
325
253
  await cleanupFolderMarker(folderPath, bucketName);
326
254
 
327
- res.json({ success: true, message: 'File uploaded successfully', key: key });
255
+ res.json({ success: true, message: 'File uploaded successfully', key });
328
256
  } catch (uploadError) {
329
- // Handle file already exists error from atomic operation
330
257
  if (uploadError.message.includes('already exists')) {
331
258
  return res.status(409).json({ success: false, error: `File already exists: ${req.file.originalname}` });
332
259
  }
@@ -336,24 +263,19 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
336
263
  console.error("Error uploading file:", error);
337
264
  return sendApiError(res, error, 'Upload failed');
338
265
  }
339
- });
266
+ }
340
267
 
341
- // S3 Multipart Upload — Step 1: Initiate
342
- // Body: { fileName, prefix?, contentType? }
343
- // Returns: { uploadId, key }
344
- router.post('/upload-init', validateSessionAndRole('SuperAdmin'), async (req, res) => {
268
+ export async function initiateMultipartUpload(req, res) {
345
269
  try {
346
270
  const bucketName = req.activeBucket;
347
271
  const { fileName, prefix = '', contentType = 'application/octet-stream' } = req.body;
348
272
  if (!fileName) return res.status(400).json({ success: false, error: 'fileName is required' });
349
273
 
350
- let effectivePrefix;
351
- try { effectivePrefix = ensurePrefix(prefix); } catch (e) { return res.status(400).json({ success: false, error: e.message }); }
352
- const key = `${effectivePrefix.replace(/\/+$/, '')}/${fileName}`;
274
+ const effectivePrefix = resolvePrefix(req, res, prefix);
275
+ if (effectivePrefix === null) return;
276
+
277
+ const key = buildKey(effectivePrefix, fileName);
353
278
 
354
- // Check if file exists before initiating multipart upload
355
- // Note: This is still slightly racy, but multipart uploads take time so collision risk is lower
356
- // A complete solution would require server-side locking or unique upload IDs
357
279
  const exists = await fileExists(key, bucketName);
358
280
  if (exists) return res.status(409).json({ success: false, error: `File already exists: ${fileName}` });
359
281
 
@@ -363,12 +285,9 @@ router.post('/upload-init', validateSessionAndRole('SuperAdmin'), async (req, re
363
285
  console.error('[mbkbucket] upload-init failed:', err);
364
286
  return sendApiError(res, err, 'upload-init failed');
365
287
  }
366
- });
288
+ }
367
289
 
368
- // S3 Multipart Upload — Step 2: Upload a part
369
- // FormData: chunk (file), partNumber (1-based integer), uploadId, key
370
- // Returns: { partNumber, ETag }
371
- router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), uploadChunk, async (req, res) => {
290
+ export async function uploadChunk(req, res) {
372
291
  try {
373
292
  const bucketName = req.activeBucket;
374
293
  const { uploadId, key, partNumber } = req.body;
@@ -386,12 +305,9 @@ router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), uploadChunk,
386
305
  console.error('[mbkbucket] upload-chunk failed:', err);
387
306
  return sendApiError(res, err, 'upload-chunk failed');
388
307
  }
389
- });
308
+ }
390
309
 
391
- // S3 Multipart Upload — Step 3: Complete
392
- // Body: { uploadId, key, parts: [{ partNumber, ETag }] }
393
- // Returns: { key }
394
- router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
310
+ export async function completeUpload(req, res) {
395
311
  try {
396
312
  const bucketName = req.activeBucket;
397
313
  const { uploadId, key, parts } = req.body;
@@ -401,7 +317,6 @@ router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req
401
317
 
402
318
  const result = await completeMultipartUpload(key, uploadId, parts, bucketName);
403
319
 
404
- // Clean up folder marker if it exists in the parent directory
405
320
  const folderPath = result.key.substring(0, result.key.lastIndexOf('/') + 1);
406
321
  await cleanupFolderMarker(folderPath, bucketName);
407
322
 
@@ -410,11 +325,9 @@ router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req
410
325
  console.error('[mbkbucket] upload-complete failed:', err);
411
326
  return sendApiError(res, err, 'upload-complete failed');
412
327
  }
413
- });
328
+ }
414
329
 
415
- // S3 Multipart Upload — Abort (cleanup on client cancel/error)
416
- // Body: { uploadId, key }
417
- router.post('/upload-abort', validateSessionAndRole('SuperAdmin'), async (req, res) => {
330
+ export async function abortUpload(req, res) {
418
331
  try {
419
332
  const bucketName = req.activeBucket;
420
333
  const { uploadId, key } = req.body;
@@ -426,30 +339,26 @@ router.post('/upload-abort', validateSessionAndRole('SuperAdmin'), async (req, r
426
339
  console.error('[mbkbucket] upload-abort failed:', err);
427
340
  return sendApiError(res, err, 'upload-abort failed');
428
341
  }
429
- });
342
+ }
430
343
 
431
- // Create folder (zero-byte object with trailing slash)
432
- router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req, res) => {
344
+ export async function createFolder(req, res) {
433
345
  try {
434
346
  const bucketName = req.activeBucket;
435
347
  const { prefix = '', folderName } = req.body;
436
348
  if (!folderName) return res.status(400).json({ success: false, error: 'folderName is required' });
437
349
 
438
- let effectivePrefix;
439
- try { effectivePrefix = ensurePrefix(prefix); } catch (e) { return res.status(400).json({ success: false, error: e.message }); }
350
+ const effectivePrefix = resolvePrefix(req, res, prefix);
351
+ if (effectivePrefix === null) return;
440
352
 
441
353
  const folderKey = `${effectivePrefix.replace(/\/+$/, '')}/${folderName}/`;
442
354
 
443
- // Check if folder marker already exists
444
355
  const markerExists = await fileExists(folderKey, bucketName);
445
356
  if (markerExists) return res.status(409).json({ success: false, error: 'Folder already exists' });
446
357
 
447
- // Check if any files already exist under this prefix (folder already has content)
448
358
  const existingFiles = await listfiles(folderKey, { maxKeys: 1, bucketName });
449
359
  const hasContent = existingFiles.Contents && existingFiles.Contents.length > 0;
450
360
 
451
361
  if (hasContent) {
452
- // Folder already exists with content, no need to create marker
453
362
  return res.json({
454
363
  success: true,
455
364
  message: 'Folder already exists with content',
@@ -458,7 +367,6 @@ router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req,
458
367
  });
459
368
  }
460
369
 
461
- // Create zero-byte object to represent empty folder
462
370
  await uploadFile(folderKey, Buffer.alloc(0), 'application/x-empty', {
463
371
  metadata: { 'folder': 'true', 'marker': 'true' },
464
372
  bucketName
@@ -469,15 +377,14 @@ router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req,
469
377
  console.error('Create folder failed:', err);
470
378
  return sendApiError(res, err, 'Create folder failed');
471
379
  }
472
- });
380
+ }
473
381
 
474
- router.post('/delete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
382
+ export async function deleteItems(req, res) {
475
383
  try {
476
384
  const bucketName = req.activeBucket;
477
385
  const { key, keys, folder } = req.body;
478
386
 
479
387
  if (keys && Array.isArray(keys) && keys.length) {
480
- // Bulk delete
481
388
  await deleteFiles(keys, bucketName);
482
389
  return res.json({ success: true, message: 'Files deleted successfully' });
483
390
  }
@@ -487,7 +394,6 @@ router.post('/delete', validateSessionAndRole('SuperAdmin'), async (req, res) =>
487
394
  let keyToDelete;
488
395
  try { keyToDelete = ensureKeyHasAppPrefix(key); } catch (e) { return res.status(400).json({ success: false, error: e.message }); }
489
396
 
490
- // If caller indicates it's a folder (or key ends with '/'), delete all objects under the prefix
491
397
  if (folder === true || String(keyToDelete).endsWith('/')) {
492
398
  await deleteFolder(keyToDelete, bucketName);
493
399
  return res.json({ success: true, message: 'Folder deleted successfully' });
@@ -499,31 +405,43 @@ router.post('/delete', validateSessionAndRole('SuperAdmin'), async (req, res) =>
499
405
  console.error("Error deleting file:", error);
500
406
  return sendApiError(res, error, 'Delete failed');
501
407
  }
502
- });
408
+ }
409
+
410
+ export async function downloadFileHandler(req, res) {
411
+ // Track whether this request has been aborted (client disconnected)
412
+ let aborted = false;
413
+ req.once('aborted', () => { aborted = true; });
414
+ req.once('close', () => { aborted = true; });
503
415
 
504
- router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
505
- let stream = null;
506
416
  try {
507
417
  const bucketName = req.activeBucket;
508
- const key = req.params.key;
509
418
  let keyToUse;
510
419
  try {
511
- keyToUse = ensureKeyHasAppPrefix(key);
420
+ keyToUse = ensureKeyHasAppPrefix(req.params.key);
512
421
  } catch (e) {
513
422
  return res.status(400).json({ success: false, error: e.message });
514
423
  }
515
- const result = await downloadFile(keyToUse, { bucketName });
516
- stream = result.Body;
517
424
 
518
- res.setHeader('Content-Disposition', `attachment; filename="${getBaseName(keyToUse).replace(/"/g, '\\"')}"`);
519
- const contentType = result.ContentType || 'application/octet-stream';
520
- res.setHeader('Content-Type', contentType);
425
+ // If client already disconnected before we fetched, bail early
426
+ if (aborted) return;
521
427
 
522
- // Set Content-Length header for proper download progress indication
523
- if (result.ContentLength) {
524
- res.setHeader('Content-Length', result.ContentLength);
428
+ const result = await downloadFile(keyToUse, { bucketName });
429
+
430
+ // Re-check: client may have disconnected while waiting for S3
431
+ if (aborted) {
432
+ if (result.Body && typeof result.Body.destroy === 'function') result.Body.destroy();
433
+ return;
525
434
  }
526
435
 
436
+ const fileName = getBaseName(keyToUse).replace(/"/g, '\\"');
437
+ const contentType = result.ContentType || 'application/octet-stream';
438
+ const contentLength = result.ContentLength;
439
+
440
+ // Set headers BEFORE piping — this allows the browser to show filename
441
+ // and progress bar even before the first byte arrives
442
+ res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
443
+ res.setHeader('Content-Type', contentType);
444
+ if (contentLength) res.setHeader('Content-Length', contentLength);
527
445
  if (result.ETag) res.setHeader('ETag', result.ETag);
528
446
  if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
529
447
 
@@ -533,9 +451,8 @@ router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req
533
451
  || typeLower.includes('xml')
534
452
  || typeLower.includes('javascript')
535
453
  || typeLower.includes('typescript');
536
- const isLargeFile = Number(result.ContentLength || 0) > 50 * 1024 * 1024;
454
+ const isLargeFile = Number(contentLength || 0) > 50 * 1024 * 1024;
537
455
 
538
- // Smarter cache policy for authenticated downloads.
539
456
  if (isSensitiveText) {
540
457
  res.setHeader('Cache-Control', 'private, no-store');
541
458
  } else if (isLargeFile) {
@@ -544,42 +461,29 @@ router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req
544
461
  res.setHeader('Cache-Control', 'private, max-age=1800, must-revalidate');
545
462
  }
546
463
 
547
- // Proper stream error handling
548
- stream.on('error', (err) => {
549
- console.error('Download stream error:', err);
550
- if (!res.headersSent) {
551
- res.status(500).json({ message: 'Error streaming file', error: err.message });
464
+ // Use pipeline for proper backpressure + cleanup. On abort, the
465
+ // pipeline will be rejected and we clean up the S3 stream.
466
+ try {
467
+ await pipeline(result.Body, res);
468
+ } catch (pipeErr) {
469
+ // Aborted by client — expected, not an error
470
+ if (aborted) {
471
+ if (result.Body && typeof result.Body.destroy === 'function') result.Body.destroy();
472
+ return;
552
473
  }
553
- if (stream && typeof stream.destroy === 'function') stream.destroy();
554
- });
555
-
556
- // Clean up on client disconnect
557
- req.on('close', () => {
558
- if (stream && typeof stream.destroy === 'function') stream.destroy();
559
- });
560
-
561
- // Handle request timeout
562
- const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
563
- req.setTimeout(timeoutDuration, () => {
564
- console.error(`Download timeout (${timeoutDuration}ms) for: ${keyToUse}`);
565
- if (stream && typeof stream.destroy === 'function') stream.destroy();
566
- });
474
+ throw pipeErr;
475
+ }
476
+ } catch (error) {
477
+ // Don't try to send error response if client already disconnected
478
+ if (aborted) return;
567
479
 
568
- stream.pipe(res);
569
- }
570
- catch (error) {
571
480
  console.error("Error downloading file:", error);
572
-
573
- // Clean up stream on error
574
- if (stream && typeof stream.destroy === 'function') stream.destroy();
575
- if (error.message.includes('File not found')) {
481
+ if (error.message && error.message.includes('File not found')) {
576
482
  res.status(404).json({ message: "File not found", key: req.params.key });
577
- } else if (error.message.includes('Access denied')) {
483
+ } else if (error.message && error.message.includes('Access denied')) {
578
484
  res.status(403).json({ message: "Access denied" });
579
- } else {
485
+ } else if (!res.headersSent) {
580
486
  res.status(500).json({ message: "Download failed", error: error.message });
581
487
  }
582
488
  }
583
- });
584
-
585
- export default router;
489
+ }