mbkbucket 1.5.0 → 2.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.
@@ -0,0 +1,86 @@
1
+ function normalizeBooleanLike(value) {
2
+ if (typeof value === 'boolean') return value;
3
+ if (typeof value === 'string') {
4
+ const normalized = value.trim().toLowerCase();
5
+ if (['true', 't', '1', 'yes', 'y', 'on'].includes(normalized)) return true;
6
+ if (['false', 'f', '0', 'no', 'n', 'off'].includes(normalized)) return false;
7
+ }
8
+ return value;
9
+ }
10
+
11
+ export function parseAndValidateMbkbucketVar(rawValue) {
12
+ const errors = [];
13
+ let parsed;
14
+
15
+ try {
16
+ parsed = rawValue ? JSON.parse(rawValue) : {};
17
+ } catch {
18
+ throw new Error('Invalid JSON in process.env.mbkbucketVar');
19
+ }
20
+
21
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
22
+ throw new Error('mbkbucketVar must be a valid object');
23
+ }
24
+
25
+ const defaults = {
26
+ p_view_inline: true,
27
+ publiView_enabled: false
28
+ };
29
+
30
+ for (const [key, defaultVal] of Object.entries(defaults)) {
31
+ const current = parsed[key];
32
+ if (current === undefined || (typeof current === 'string' && current.trim() === '')) {
33
+ parsed[key] = defaultVal;
34
+ }
35
+ }
36
+
37
+ parsed.p_view_inline = normalizeBooleanLike(parsed.p_view_inline);
38
+ parsed.publiView_enabled = normalizeBooleanLike(parsed.publiView_enabled);
39
+
40
+ if (typeof parsed.p_view_inline !== 'boolean') {
41
+ errors.push('mbkbucketVar.p_view_inline must be a boolean if provided');
42
+ }
43
+ if (typeof parsed.publiView_enabled !== 'boolean') {
44
+ errors.push('mbkbucketVar.publiView_enabled must be a boolean if provided');
45
+ }
46
+
47
+ if (errors.length) {
48
+ throw new Error(errors.join(' | '));
49
+ }
50
+
51
+ return parsed;
52
+ }
53
+
54
+ export function parseAndValidateBucketConnection(rawValue) {
55
+ if (!rawValue) {
56
+ return null;
57
+ }
58
+
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(rawValue);
62
+ } catch {
63
+ throw new Error('BucketConnection is not valid JSON');
64
+ }
65
+
66
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.keys(parsed).length === 0) {
67
+ throw new Error('BucketConnection must be a non-empty object');
68
+ }
69
+
70
+ const requiredFields = ['BUCKET_NAME', 'ACCESS_KEY_ID', 'SECRET_ACCESS_KEY', 'ENDPOINT'];
71
+
72
+ for (const [bucketName, config] of Object.entries(parsed)) {
73
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
74
+ throw new Error(`Bucket '${bucketName}' configuration must be an object, not a string. Remove quotes around the inner JSON object.`);
75
+ }
76
+
77
+ const missingFields = requiredFields.filter((field) => !config[field]);
78
+ if (missingFields.length) {
79
+ throw new Error(`Bucket '${bucketName}' is missing required fields: ${missingFields.join(', ')}`);
80
+ }
81
+ }
82
+
83
+ return parsed;
84
+ }
85
+
86
+ export { normalizeBooleanLike };
package/lib/routes/api.js CHANGED
@@ -4,12 +4,79 @@ import { listfiles, uploadFile, deleteFile, deleteFiles, deleteFolder, downloadF
4
4
  import multer from "multer";
5
5
 
6
6
  const router = express.Router();
7
- const upload = multer({ storage: multer.memoryStorage() });
7
+
8
+ function classifyApiError(error, fallbackMessage = 'Request failed') {
9
+ const message = String(error?.message || fallbackMessage);
10
+ const lower = message.toLowerCase();
11
+
12
+ if (lower.includes('bucketconnection environment variable is not set')) {
13
+ return { status: 503, code: 'BUCKET_CONFIG_ERROR', message };
14
+ }
15
+ if (lower.includes('no bucket selected') || lower.includes('not found in bucketconnection')) {
16
+ return { status: 400, code: 'INVALID_BUCKET', message };
17
+ }
18
+ if (lower.includes('already exists')) {
19
+ return { status: 409, code: 'CONFLICT', message };
20
+ }
21
+ if (lower.includes('not found')) {
22
+ return { status: 404, code: 'NOT_FOUND', message };
23
+ }
24
+ if (lower.includes('access denied')) {
25
+ return { status: 403, code: 'ACCESS_DENIED', message };
26
+ }
27
+ if (lower.includes('required') || lower.includes('invalid') || lower.includes('must be')) {
28
+ return { status: 400, code: 'VALIDATION_ERROR', message };
29
+ }
30
+ return { status: 500, code: 'INTERNAL_ERROR', message };
31
+ }
32
+
33
+ function sendApiError(res, error, fallbackMessage = 'Request failed') {
34
+ const mapped = classifyApiError(error, fallbackMessage);
35
+ return res.status(mapped.status).json({ success: false, code: mapped.code, error: mapped.message });
36
+ }
37
+
38
+ // Central bucket guard for all API routes under /mbkbucket
39
+ router.use((req, res, next) => {
40
+ if (req.bucketResolveError) {
41
+ return sendApiError(res, req.bucketResolveError, 'Invalid bucket selection');
42
+ }
43
+ if (!req.activeBucket) {
44
+ return sendApiError(res, new Error('No bucket selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.'));
45
+ }
46
+ next();
47
+ });
48
+
49
+ // This is the maximum size we allow per chunk upload. It should be >=
50
+ // the client chunk size (5MB) and leave room for any encoding overhead.
51
+ // If you need to support larger chunks, increase this accordingly.
52
+ const MAX_CHUNK_SIZE = 50 * 1024 * 1024; // 50 MB
53
+
54
+ const upload = multer({
55
+ storage: multer.memoryStorage(),
56
+ limits: { fileSize: MAX_CHUNK_SIZE }
57
+ });
58
+
59
+ const uploadChunk = (req, res, next) => {
60
+ upload.single('chunk')(req, res, (err) => {
61
+ if (err) {
62
+ // Multer throws an error if the file is too large (or another multipart parsing issue occurs)
63
+ if (err.code === 'LIMIT_FILE_SIZE') {
64
+ return res.status(413).json({
65
+ success: false,
66
+ error: `Chunk too large (max ${Math.round(MAX_CHUNK_SIZE / (1024 * 1024))} MB).`,
67
+ });
68
+ }
69
+ return next(err);
70
+ }
71
+ next();
72
+ });
73
+ };
8
74
 
9
75
  // API endpoint for multipart upload cleanup
10
76
  router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async (req, res) => {
11
77
  try {
12
78
  const { olderThanDays = 7, prefix = '' } = req.body;
79
+ const bucketName = req.activeBucket;
13
80
 
14
81
  // Validate olderThanDays
15
82
  const days = parseInt(olderThanDays, 10);
@@ -18,7 +85,7 @@ router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async
18
85
  }
19
86
 
20
87
  console.log(`[mbkbucket] Starting cleanup of incomplete uploads older than ${days} days`);
21
- const result = await cleanupIncompleteMultipartUploads(days, prefix);
88
+ const result = await cleanupIncompleteMultipartUploads(days, prefix, bucketName);
22
89
 
23
90
  res.json({
24
91
  success: true,
@@ -27,10 +94,7 @@ router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async
27
94
  });
28
95
  } catch (error) {
29
96
  console.error('[mbkbucket] Cleanup failed:', error);
30
- res.status(500).json({
31
- success: false,
32
- error: error.message
33
- });
97
+ return sendApiError(res, error, 'Cleanup failed');
34
98
  }
35
99
  });
36
100
 
@@ -38,7 +102,8 @@ router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async
38
102
  router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), async (req, res) => {
39
103
  try {
40
104
  const { prefix = '' } = req.query;
41
- const uploads = await listIncompleteMultipartUploads(prefix);
105
+ const bucketName = req.activeBucket;
106
+ const uploads = await listIncompleteMultipartUploads(prefix, bucketName);
42
107
 
43
108
  res.json({
44
109
  success: true,
@@ -52,10 +117,7 @@ router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), asyn
52
117
  });
53
118
  } catch (error) {
54
119
  console.error('[mbkbucket] Failed to list incomplete uploads:', error);
55
- res.status(500).json({
56
- success: false,
57
- error: error.message
58
- });
120
+ return sendApiError(res, error, 'Failed to list incomplete uploads');
59
121
  }
60
122
  });
61
123
 
@@ -63,6 +125,7 @@ router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), asyn
63
125
  router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res) => {
64
126
  const { prefix = '', page = '1', search = '', recursive = 'true', token = '' } = req.query;
65
127
  const pageSize = 100;
128
+ const bucketName = req.activeBucket;
66
129
  let effectivePrefix;
67
130
  try {
68
131
  effectivePrefix = ensurePrefix(prefix);
@@ -99,7 +162,8 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
99
162
  const result = await listfiles(listPrefix, {
100
163
  continuationToken: token || undefined, // Use token from query if provided
101
164
  delimiter: '/',
102
- maxKeys: 1000 // Fetch a healthy batch.
165
+ maxKeys: 1000, // Fetch a healthy batch.
166
+ bucketName
103
167
  });
104
168
 
105
169
  files = result.Contents || [];
@@ -116,7 +180,7 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
116
180
 
117
181
  // Fetch all files recursively (handling S3 pagination)
118
182
  do {
119
- const result = await listfiles(effectivePrefix, { continuationToken });
183
+ const result = await listfiles(effectivePrefix, { continuationToken, bucketName });
120
184
  if (result.Contents) {
121
185
  // Filter out folder marker objects (zero-byte files ending with /)
122
186
  const actualFiles = result.Contents.filter(f => !f.Key.endsWith('/'));
@@ -128,7 +192,7 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
128
192
  // If no files found under the app prefixed path, try root as a legacy fallback
129
193
  if (!files.length && !search) {
130
194
  try {
131
- const rootResult = await listfiles('');
195
+ const rootResult = await listfiles('', { bucketName });
132
196
  if ((rootResult.Contents || []).length) {
133
197
  files = rootResult.Contents || [];
134
198
  usedPrefix = '';
@@ -193,9 +257,11 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
193
257
 
194
258
  } catch (error) {
195
259
  console.error("Error listing files:", error);
196
- res.status(500).json({
260
+ const mapped = classifyApiError(error, 'Error listing files');
261
+ return res.status(mapped.status).json({
197
262
  success: false,
198
- error: "Error listing files: " + error.message,
263
+ code: mapped.code,
264
+ error: mapped.message,
199
265
  files: [],
200
266
  totalFiles: 0
201
267
  });
@@ -205,6 +271,7 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
205
271
  // Standard single-file upload
206
272
  router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file'), async (req, res) => {
207
273
  try {
274
+ const bucketName = req.activeBucket;
208
275
  if (!req.file) {
209
276
  return res.status(400).json({ success: false, error: 'No file selected' });
210
277
  }
@@ -225,6 +292,7 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
225
292
  'upload-source': 'web-portal',
226
293
  'user-agent': req.headers['user-agent'] || 'unknown'
227
294
  },
295
+ bucketName,
228
296
  preventOverwrite: true // Use atomic operation to prevent race conditions
229
297
  };
230
298
 
@@ -237,12 +305,12 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
237
305
  if (folderPath) {
238
306
  const folderMarkerKey = folderPath; // Already ends with /
239
307
  try {
240
- const markerExists = await fileExists(folderMarkerKey);
308
+ const markerExists = await fileExists(folderMarkerKey, bucketName);
241
309
  if (markerExists) {
242
310
  // Check if this is actually a folder marker (zero-byte file)
243
- const metadata = await getFileMetadata(folderMarkerKey);
311
+ const metadata = await getFileMetadata(folderMarkerKey, bucketName);
244
312
  if (metadata.ContentLength === 0 || metadata.Metadata?.marker === 'true') {
245
- await deleteFile(folderMarkerKey);
313
+ await deleteFile(folderMarkerKey, bucketName);
246
314
  console.log(`[mbkbucket] Cleaned up folder marker: ${folderMarkerKey}`);
247
315
  }
248
316
  }
@@ -262,7 +330,7 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
262
330
  }
263
331
  } catch (error) {
264
332
  console.error("Error uploading file:", error);
265
- res.status(500).json({ success: false, error: `Upload failed: ${error.message}` });
333
+ return sendApiError(res, error, 'Upload failed');
266
334
  }
267
335
  });
268
336
 
@@ -271,6 +339,7 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
271
339
  // Returns: { uploadId, key }
272
340
  router.post('/upload-init', validateSessionAndRole('SuperAdmin'), async (req, res) => {
273
341
  try {
342
+ const bucketName = req.activeBucket;
274
343
  const { fileName, prefix = '', contentType = 'application/octet-stream' } = req.body;
275
344
  if (!fileName) return res.status(400).json({ success: false, error: 'fileName is required' });
276
345
 
@@ -281,22 +350,23 @@ router.post('/upload-init', validateSessionAndRole('SuperAdmin'), async (req, re
281
350
  // Check if file exists before initiating multipart upload
282
351
  // Note: This is still slightly racy, but multipart uploads take time so collision risk is lower
283
352
  // A complete solution would require server-side locking or unique upload IDs
284
- const exists = await fileExists(key);
353
+ const exists = await fileExists(key, bucketName);
285
354
  if (exists) return res.status(409).json({ success: false, error: `File already exists: ${fileName}` });
286
355
 
287
- const result = await createMultipartUpload(key, contentType);
356
+ const result = await createMultipartUpload(key, contentType, {}, bucketName);
288
357
  return res.json({ success: true, uploadId: result.uploadId, key: result.key });
289
358
  } catch (err) {
290
359
  console.error('[mbkbucket] upload-init failed:', err);
291
- return res.status(500).json({ success: false, error: err.message });
360
+ return sendApiError(res, err, 'upload-init failed');
292
361
  }
293
362
  });
294
363
 
295
364
  // S3 Multipart Upload — Step 2: Upload a part
296
365
  // FormData: chunk (file), partNumber (1-based integer), uploadId, key
297
366
  // Returns: { partNumber, ETag }
298
- router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), upload.single('chunk'), async (req, res) => {
367
+ router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), uploadChunk, async (req, res) => {
299
368
  try {
369
+ const bucketName = req.activeBucket;
300
370
  const { uploadId, key, partNumber } = req.body;
301
371
  if (!req.file || !uploadId || !key || !partNumber) {
302
372
  return res.status(400).json({ success: false, error: 'chunk file, uploadId, key, and partNumber are required' });
@@ -306,11 +376,11 @@ router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), upload.single
306
376
  return res.status(400).json({ success: false, error: 'partNumber must be an integer between 1 and 10000' });
307
377
  }
308
378
 
309
- const result = await uploadPart(key, uploadId, part, req.file.buffer);
379
+ const result = await uploadPart(key, uploadId, part, req.file.buffer, bucketName);
310
380
  return res.json({ success: true, partNumber: result.partNumber, ETag: result.ETag });
311
381
  } catch (err) {
312
382
  console.error('[mbkbucket] upload-chunk failed:', err);
313
- return res.status(500).json({ success: false, error: err.message });
383
+ return sendApiError(res, err, 'upload-chunk failed');
314
384
  }
315
385
  });
316
386
 
@@ -319,23 +389,24 @@ router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), upload.single
319
389
  // Returns: { key }
320
390
  router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
321
391
  try {
392
+ const bucketName = req.activeBucket;
322
393
  const { uploadId, key, parts } = req.body;
323
394
  if (!uploadId || !key || !Array.isArray(parts) || !parts.length) {
324
395
  return res.status(400).json({ success: false, error: 'uploadId, key, and parts array are required' });
325
396
  }
326
397
 
327
- const result = await completeMultipartUpload(key, uploadId, parts);
398
+ const result = await completeMultipartUpload(key, uploadId, parts, bucketName);
328
399
 
329
400
  // Clean up folder marker if it exists in the parent directory
330
401
  const folderPath = result.key.substring(0, result.key.lastIndexOf('/') + 1);
331
402
  if (folderPath) {
332
403
  const folderMarkerKey = folderPath;
333
404
  try {
334
- const markerExists = await fileExists(folderMarkerKey);
405
+ const markerExists = await fileExists(folderMarkerKey, bucketName);
335
406
  if (markerExists) {
336
- const metadata = await getFileMetadata(folderMarkerKey);
407
+ const metadata = await getFileMetadata(folderMarkerKey, bucketName);
337
408
  if (metadata.ContentLength === 0 || metadata.Metadata?.marker === 'true') {
338
- await deleteFile(folderMarkerKey);
409
+ await deleteFile(folderMarkerKey, bucketName);
339
410
  console.log(`[mbkbucket] Cleaned up folder marker: ${folderMarkerKey}`);
340
411
  }
341
412
  }
@@ -347,7 +418,7 @@ router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req
347
418
  return res.json({ success: true, message: 'File uploaded successfully', key: result.key });
348
419
  } catch (err) {
349
420
  console.error('[mbkbucket] upload-complete failed:', err);
350
- return res.status(500).json({ success: false, error: err.message });
421
+ return sendApiError(res, err, 'upload-complete failed');
351
422
  }
352
423
  });
353
424
 
@@ -355,20 +426,22 @@ router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req
355
426
  // Body: { uploadId, key }
356
427
  router.post('/upload-abort', validateSessionAndRole('SuperAdmin'), async (req, res) => {
357
428
  try {
429
+ const bucketName = req.activeBucket;
358
430
  const { uploadId, key } = req.body;
359
431
  if (!uploadId || !key) return res.status(400).json({ success: false, error: 'uploadId and key are required' });
360
432
 
361
- await abortMultipartUpload(key, uploadId);
433
+ await abortMultipartUpload(key, uploadId, bucketName);
362
434
  return res.json({ success: true, message: 'Multipart upload aborted' });
363
435
  } catch (err) {
364
436
  console.error('[mbkbucket] upload-abort failed:', err);
365
- return res.status(500).json({ success: false, error: err.message });
437
+ return sendApiError(res, err, 'upload-abort failed');
366
438
  }
367
439
  });
368
440
 
369
441
  // Create folder (zero-byte object with trailing slash)
370
442
  router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req, res) => {
371
443
  try {
444
+ const bucketName = req.activeBucket;
372
445
  const { prefix = '', folderName } = req.body;
373
446
  if (!folderName) return res.status(400).json({ success: false, error: 'folderName is required' });
374
447
 
@@ -378,11 +451,11 @@ router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req,
378
451
  const folderKey = `${effectivePrefix.replace(/\/+$/, '')}/${folderName}/`;
379
452
 
380
453
  // Check if folder marker already exists
381
- const markerExists = await fileExists(folderKey);
454
+ const markerExists = await fileExists(folderKey, bucketName);
382
455
  if (markerExists) return res.status(409).json({ success: false, error: 'Folder already exists' });
383
456
 
384
457
  // Check if any files already exist under this prefix (folder already has content)
385
- const existingFiles = await listfiles(folderKey, { maxKeys: 1 });
458
+ const existingFiles = await listfiles(folderKey, { maxKeys: 1, bucketName });
386
459
  const hasContent = existingFiles.Contents && existingFiles.Contents.length > 0;
387
460
 
388
461
  if (hasContent) {
@@ -397,23 +470,25 @@ router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req,
397
470
 
398
471
  // Create zero-byte object to represent empty folder
399
472
  await uploadFile(folderKey, Buffer.alloc(0), 'application/x-empty', {
400
- metadata: { 'folder': 'true', 'marker': 'true' }
473
+ metadata: { 'folder': 'true', 'marker': 'true' },
474
+ bucketName
401
475
  });
402
476
 
403
477
  return res.json({ success: true, message: 'Folder created', key: folderKey });
404
478
  } catch (err) {
405
479
  console.error('Create folder failed:', err);
406
- return res.status(500).json({ success: false, error: err.message });
480
+ return sendApiError(res, err, 'Create folder failed');
407
481
  }
408
482
  });
409
483
 
410
484
  router.post('/delete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
411
485
  try {
486
+ const bucketName = req.activeBucket;
412
487
  const { key, keys, folder } = req.body;
413
488
 
414
489
  if (keys && Array.isArray(keys) && keys.length) {
415
490
  // Bulk delete
416
- await deleteFiles(keys);
491
+ await deleteFiles(keys, bucketName);
417
492
  return res.json({ success: true, message: 'Files deleted successfully' });
418
493
  }
419
494
 
@@ -424,21 +499,22 @@ router.post('/delete', validateSessionAndRole('SuperAdmin'), async (req, res) =>
424
499
 
425
500
  // If caller indicates it's a folder (or key ends with '/'), delete all objects under the prefix
426
501
  if (folder === true || String(keyToDelete).endsWith('/')) {
427
- await deleteFolder(keyToDelete);
502
+ await deleteFolder(keyToDelete, bucketName);
428
503
  return res.json({ success: true, message: 'Folder deleted successfully' });
429
504
  }
430
505
 
431
- await deleteFile(keyToDelete);
506
+ await deleteFile(keyToDelete, bucketName);
432
507
  res.json({ success: true, message: 'File deleted successfully' });
433
508
  } catch (error) {
434
509
  console.error("Error deleting file:", error);
435
- res.status(500).json({ success: false, error: `Delete failed: ${error.message}` });
510
+ return sendApiError(res, error, 'Delete failed');
436
511
  }
437
512
  });
438
513
 
439
514
  router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
440
515
  let stream = null;
441
516
  try {
517
+ const bucketName = req.activeBucket;
442
518
  const key = req.params.key;
443
519
  let keyToUse;
444
520
  try {
@@ -446,19 +522,37 @@ router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req
446
522
  } catch (e) {
447
523
  return res.status(400).json({ success: false, error: e.message });
448
524
  }
449
- const result = await downloadFile(keyToUse);
525
+ const result = await downloadFile(keyToUse, { bucketName });
450
526
  stream = result.Body;
451
527
 
452
528
  res.setHeader('Content-Disposition', `attachment; filename="${keyToUse.split('/').pop()}"`);
453
- res.setHeader('Content-Type', result.ContentType || 'application/octet-stream');
529
+ const contentType = result.ContentType || 'application/octet-stream';
530
+ res.setHeader('Content-Type', contentType);
454
531
 
455
532
  // Set Content-Length header for proper download progress indication
456
533
  if (result.ContentLength) {
457
534
  res.setHeader('Content-Length', result.ContentLength);
458
535
  }
459
536
 
460
- // Add cache headers for downloads
461
- res.setHeader('Cache-Control', 'private, max-age=3600');
537
+ if (result.ETag) res.setHeader('ETag', result.ETag);
538
+ if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
539
+
540
+ const typeLower = String(contentType).toLowerCase();
541
+ const isSensitiveText = typeLower.startsWith('text/')
542
+ || typeLower.includes('json')
543
+ || typeLower.includes('xml')
544
+ || typeLower.includes('javascript')
545
+ || typeLower.includes('typescript');
546
+ const isLargeFile = Number(result.ContentLength || 0) > 50 * 1024 * 1024;
547
+
548
+ // Smarter cache policy for authenticated downloads.
549
+ if (isSensitiveText) {
550
+ res.setHeader('Cache-Control', 'private, no-store');
551
+ } else if (isLargeFile) {
552
+ res.setHeader('Cache-Control', 'private, max-age=600, must-revalidate');
553
+ } else {
554
+ res.setHeader('Cache-Control', 'private, max-age=1800, must-revalidate');
555
+ }
462
556
 
463
557
  // Proper stream error handling
464
558
  stream.on('error', (err) => {
@@ -3,13 +3,82 @@ import infoRoutes from "./info.js";
3
3
  import viewRoutes from "./view.js";
4
4
  import apiRoutes from "./api.js";
5
5
  import { validateSessionAndRole, renderPage, mbkautheVar } from "mbkauthe";
6
+ import { resolveBucketName, getAvailableBucketNames } from "../s3.js";
6
7
 
7
8
  const router = express.Router();
8
9
 
10
+ function renderBucketDashboard(req, res) {
11
+ const allBuckets = getAvailableBucketNames();
12
+ const configuredDefaultBucket = mbkautheVar?.bucket;
13
+
14
+ let bucketfilePath;
15
+
16
+ if(mbkautheVar?.APP_NAME === "portal") {
17
+ bucketfilePath ="bucketportal.handlebars";
18
+ } else {
19
+ bucketfilePath = "bucket.handlebars";
20
+ }
21
+
22
+
23
+ let selectedBucket;
24
+
25
+ try {
26
+ selectedBucket = resolveBucketName(req.query.bucket);
27
+ } catch (err) {
28
+ const fallbackSelected = mbkautheVar?.bucket;
29
+ const bucketOptions = allBuckets.map((name) => ({
30
+ name,
31
+ isSelected: name === fallbackSelected,
32
+ isDefault: name === configuredDefaultBucket
33
+ }));
34
+ return renderPage(req, res, bucketfilePath, mbkautheVar?.APP_NAME === "portal" ? true : false, {
35
+ page: "Admin Bucket",
36
+ layout: true,
37
+ bucketvar: mbkautheVar?.bucket,
38
+ selectedBucket: fallbackSelected,
39
+ bucketOptions,
40
+ APP_NAME: mbkautheVar?.APP_NAME,
41
+ message: req.query.message,
42
+ error: req.query.error || err.message,
43
+ availableBuckets: allBuckets
44
+ });
45
+ }
46
+
47
+ const bucketOptions = allBuckets.map((name) => ({
48
+ name,
49
+ isSelected: name === selectedBucket,
50
+ isDefault: name === configuredDefaultBucket
51
+ }));
52
+
53
+ renderPage(req, res, bucketfilePath, mbkautheVar?.APP_NAME === "portal" ? true : false, {
54
+ page: "Admin Bucket",
55
+ bucketvar: selectedBucket,
56
+ selectedBucket,
57
+ bucketOptions,
58
+ APP_NAME: mbkautheVar?.APP_NAME,
59
+ message: req.query.message,
60
+ error: req.query.error,
61
+ availableBuckets: allBuckets
62
+ });
63
+ }
64
+
9
65
  router.get('/mbkbucket', validateSessionAndRole('SuperAdmin'), async (req, res) => {
10
- renderPage(req, res, "bucket.handlebars", false, { page: "Admin Bucket", bucketvar: mbkautheVar?.bucket, APP_NAME: mbkautheVar?.APP_NAME, message: req.query.message, error: req.query.error });
66
+ return renderBucketDashboard(req, res);
11
67
  });
12
68
 
69
+ // Central bucket resolution for all /mbkbucket subroutes.
70
+ router.use('/mbkbucket', (req, _res, next) => {
71
+ try {
72
+ req.activeBucket = resolveBucketName(req.query.bucket);
73
+ req.bucketResolveError = null;
74
+ } catch (err) {
75
+ req.activeBucket = null;
76
+ req.bucketResolveError = err;
77
+ }
78
+ next();
79
+ });
80
+
81
+
13
82
  router.use("/mbkbucket", infoRoutes);
14
83
  router.use("/mbkbucket", viewRoutes);
15
84
  router.use("/mbkbucket", apiRoutes);