mbkbucket 3.3.1 → 3.4.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.
Files changed (35) hide show
  1. package/README.md +17 -24
  2. package/index.js +2 -12
  3. package/lib/cli.js +191 -525
  4. package/lib/public/bucketadmin.js +1 -1
  5. package/lib/src/app.js +37 -50
  6. package/lib/src/cli/auth-helper.js +39 -112
  7. package/lib/src/config/index.js +68 -96
  8. package/lib/src/config/validation.js +14 -30
  9. package/lib/src/controllers/api.controller.js +85 -176
  10. package/lib/src/controllers/dashboard.controller.js +12 -23
  11. package/lib/src/controllers/info.controller.js +17 -20
  12. package/lib/src/controllers/view.controller.js +23 -70
  13. package/lib/src/middleware/async-handler.js +1 -16
  14. package/lib/src/middleware/bucket-resolver.js +7 -13
  15. package/lib/src/middleware/error-handler.js +40 -20
  16. package/lib/src/middleware/rate-limiter.js +2 -10
  17. package/lib/src/middleware/security.js +1 -17
  18. package/lib/src/routes/api.routes.js +21 -47
  19. package/lib/src/routes/index.js +2 -10
  20. package/lib/src/routes/info.routes.js +2 -3
  21. package/lib/src/routes/view.routes.js +3 -8
  22. package/lib/src/services/s3.service.js +157 -349
  23. package/lib/src/utils/errors.js +37 -59
  24. package/lib/src/utils/helpers.js +124 -70
  25. package/package.json +13 -5
  26. /package/{lib/views → views}/bucket.handlebars +0 -0
  27. /package/{lib/views → views}/bucketadmin_alerts.handlebars +0 -0
  28. /package/{lib/views → views}/bucketadmin_delete_modal.handlebars +0 -0
  29. /package/{lib/views → views}/bucketadmin_filelist_skeleton.handlebars +0 -0
  30. /package/{lib/views → views}/bucketadmin_newfolder_modal.handlebars +0 -0
  31. /package/{lib/views → views}/bucketadmin_preview_modal.handlebars +0 -0
  32. /package/{lib/views → views}/bucketadmin_upload_modal.handlebars +0 -0
  33. /package/{lib/views → views}/bucketadmincontent.handlebars +0 -0
  34. /package/{lib/views → views}/bucketportal.handlebars +0 -0
  35. /package/{lib/views → views}/mbkbucket_info.handlebars +0 -0
@@ -1,20 +1,18 @@
1
1
  const TRUE_VALUES = new Set(['true', 't', '1', 'yes', 'y', 'on']);
2
2
  const FALSE_VALUES = new Set(['false', 'f', '0', 'no', 'n', 'off']);
3
3
 
4
- function normalizeBooleanLike(value) {
4
+ export function normalizeBooleanLike(value) {
5
5
  if (typeof value === 'boolean') return value;
6
6
  if (typeof value === 'string') {
7
- const normalized = value.trim().toLowerCase();
8
- if (TRUE_VALUES.has(normalized)) return true;
9
- if (FALSE_VALUES.has(normalized)) return false;
7
+ const norm = value.trim().toLowerCase();
8
+ if (TRUE_VALUES.has(norm)) return true;
9
+ if (FALSE_VALUES.has(norm)) return false;
10
10
  }
11
11
  return value;
12
12
  }
13
13
 
14
14
  export function parseAndValidateMbkbucketVar(rawValue) {
15
- const errors = [];
16
15
  let parsed;
17
-
18
16
  try {
19
17
  parsed = rawValue ? JSON.parse(rawValue) : {};
20
18
  } catch {
@@ -25,14 +23,10 @@ export function parseAndValidateMbkbucketVar(rawValue) {
25
23
  throw new Error('mbkbucketVar must be a valid object');
26
24
  }
27
25
 
28
- const defaults = {
29
- p_view_inline: true,
30
- publiView_enabled: false
31
- };
32
-
26
+ const defaults = { p_view_inline: true, publiView_enabled: false };
33
27
  for (const [key, defaultVal] of Object.entries(defaults)) {
34
- const current = parsed[key];
35
- if (current === undefined || (typeof current === 'string' && current.trim() === '')) {
28
+ const val = parsed[key];
29
+ if (val === undefined || (typeof val === 'string' && val.trim() === '')) {
36
30
  parsed[key] = defaultVal;
37
31
  }
38
32
  }
@@ -40,24 +34,16 @@ export function parseAndValidateMbkbucketVar(rawValue) {
40
34
  parsed.p_view_inline = normalizeBooleanLike(parsed.p_view_inline);
41
35
  parsed.publiView_enabled = normalizeBooleanLike(parsed.publiView_enabled);
42
36
 
43
- if (typeof parsed.p_view_inline !== 'boolean') {
44
- errors.push('mbkbucketVar.p_view_inline must be a boolean if provided');
45
- }
46
- if (typeof parsed.publiView_enabled !== 'boolean') {
47
- errors.push('mbkbucketVar.publiView_enabled must be a boolean if provided');
48
- }
49
-
50
- if (errors.length) {
51
- throw new Error(errors.join(' | '));
52
- }
37
+ const errors = [];
38
+ if (typeof parsed.p_view_inline !== 'boolean') errors.push('mbkbucketVar.p_view_inline must be a boolean if provided');
39
+ if (typeof parsed.publiView_enabled !== 'boolean') errors.push('mbkbucketVar.publiView_enabled must be a boolean if provided');
40
+ if (errors.length) throw new Error(errors.join(' | '));
53
41
 
54
42
  return parsed;
55
43
  }
56
44
 
57
45
  export function parseAndValidateBucketConnection(rawValue) {
58
- if (!rawValue) {
59
- return null;
60
- }
46
+ if (!rawValue) return null;
61
47
 
62
48
  let parsed;
63
49
  try {
@@ -66,7 +52,7 @@ export function parseAndValidateBucketConnection(rawValue) {
66
52
  throw new Error('BucketConnection is not valid JSON');
67
53
  }
68
54
 
69
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.keys(parsed).length === 0) {
55
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !Object.keys(parsed).length) {
70
56
  throw new Error('BucketConnection must be a non-empty object');
71
57
  }
72
58
 
@@ -77,7 +63,7 @@ export function parseAndValidateBucketConnection(rawValue) {
77
63
  throw new Error(`Bucket '${bucketName}' configuration must be an object, not a string. Remove quotes around the inner JSON object.`);
78
64
  }
79
65
 
80
- const missingFields = requiredFields.filter((field) => !config[field]);
66
+ const missingFields = requiredFields.filter(field => !config[field]);
81
67
  if (missingFields.length) {
82
68
  throw new Error(`Bucket '${bucketName}' is missing required fields: ${missingFields.join(', ')}`);
83
69
  }
@@ -85,5 +71,3 @@ export function parseAndValidateBucketConnection(rawValue) {
85
71
 
86
72
  return parsed;
87
73
  }
88
-
89
- export { normalizeBooleanLike };
@@ -1,26 +1,11 @@
1
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";
2
+ import { listfiles, uploadFile, deleteFile, deleteFiles, deleteFolder, downloadFile, getFileMetadata, fileExists, ensureKeyHasAppPrefix, ensurePrefix, createMultipartUpload, uploadPart, completeMultipartUpload, abortMultipartUpload, listIncompleteMultipartUploads, cleanupIncompleteMultipartUploads } from "../services/s3.service.js";
10
3
  import { sendApiError, classifyApiError } from "../utils/errors.js";
11
- import { getBaseName } from "../utils/helpers.js";
12
- import { createLogger } from "../utils/logger.js";
4
+ import { getBaseName, isSensitiveTextType, buildKey, normalizeKeyParam, getFolderPath, destroyStream } from "../utils/helpers.js";
5
+ import { createLogger } from "#logger";
13
6
 
14
7
  const debugApi = createLogger('api');
15
8
 
16
- // ---------------------------------------------------------------------------
17
- // Helpers
18
- // ---------------------------------------------------------------------------
19
-
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
9
  function resolvePrefix(req, res, prefix) {
25
10
  try {
26
11
  return ensurePrefix(prefix);
@@ -30,45 +15,29 @@ function resolvePrefix(req, res, prefix) {
30
15
  }
31
16
  }
32
17
 
33
- /**
34
- * Build a full S3 key from a prefix and filename, stripping trailing slashes.
35
- */
36
- function buildKey(prefix, fileName) {
37
- const cleanPrefix = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
38
- const cleanName = String(fileName || '').replace(/^\/+/, '');
39
- return cleanPrefix ? `${cleanPrefix}/${cleanName}` : cleanName;
40
- }
41
-
42
18
  async function cleanupFolderMarker(folderPath, bucketName) {
43
19
  if (!folderPath) return;
44
-
45
20
  try {
46
- const metadata = await getFileMetadata(folderPath, bucketName);
47
- if (metadata.exists && (metadata.ContentLength === 0 || metadata.Metadata?.marker === 'true')) {
21
+ const meta = await getFileMetadata(folderPath, bucketName);
22
+ if (meta.exists && (meta.ContentLength === 0 || meta.Metadata?.marker === 'true')) {
48
23
  await deleteFile(folderPath, bucketName);
49
24
  debugApi('Cleaned up folder marker: %s', folderPath);
50
25
  }
51
- } catch (cleanupErr) {
52
- debugApi('Failed to clean up folder marker: %s', cleanupErr.message);
26
+ } catch (err) {
27
+ debugApi('Failed to clean up folder marker: %s', err.message);
53
28
  }
54
29
  }
55
30
 
56
- // ---------------------------------------------------------------------------
57
- // Controller handlers
58
- // ---------------------------------------------------------------------------
59
-
60
31
  export async function cleanupUploads(req, res) {
61
32
  try {
62
33
  const { olderThanDays = 7, prefix = '' } = req.body;
63
- const bucketName = req.activeBucket;
64
-
65
34
  const days = parseInt(olderThanDays, 10);
66
35
  if (isNaN(days) || days < 1) {
67
36
  return res.status(400).json({ success: false, error: 'olderThanDays must be a positive integer' });
68
37
  }
69
38
 
70
39
  debugApi('Starting cleanup of incomplete uploads older than %s days', days);
71
- const result = await cleanupIncompleteMultipartUploads(days, prefix, bucketName);
40
+ const result = await cleanupIncompleteMultipartUploads(days, prefix, req.activeBucket);
72
41
 
73
42
  res.json({
74
43
  success: true,
@@ -84,9 +53,7 @@ export async function cleanupUploads(req, res) {
84
53
  export async function listIncompleteUploads(req, res) {
85
54
  try {
86
55
  const { prefix = '' } = req.query;
87
- const bucketName = req.activeBucket;
88
- const uploads = await listIncompleteMultipartUploads(prefix, bucketName);
89
-
56
+ const uploads = await listIncompleteMultipartUploads(prefix, req.activeBucket);
90
57
  res.json({
91
58
  success: true,
92
59
  count: uploads.length,
@@ -111,7 +78,7 @@ export async function listFiles(req, res) {
111
78
  const effectivePrefix = resolvePrefix(req, res, prefix);
112
79
  if (effectivePrefix === null) return;
113
80
 
114
- const isSearch = search && search.trim().length > 0;
81
+ const isSearch = Boolean(search && search.trim());
115
82
  const isRecursive = recursive === 'true';
116
83
  const useOptimizedListing = !isSearch && !isRecursive;
117
84
 
@@ -119,14 +86,10 @@ export async function listFiles(req, res) {
119
86
  let files = [];
120
87
  let folders = [];
121
88
  let nextContinuationToken = null;
122
- let usedPrefix = effectivePrefix;
123
- let legacyFallback = false;
124
89
 
125
90
  if (useOptimizedListing) {
126
91
  let listPrefix = effectivePrefix ? String(effectivePrefix).replace(/^\/+/, '') : '';
127
- if (listPrefix && !listPrefix.endsWith('/')) {
128
- listPrefix += '/';
129
- }
92
+ if (listPrefix && !listPrefix.endsWith('/')) listPrefix += '/';
130
93
 
131
94
  const result = await listfiles(listPrefix, {
132
95
  continuationToken: token || undefined,
@@ -140,42 +103,8 @@ export async function listFiles(req, res) {
140
103
  .filter(p => p.Prefix !== listPrefix && p.Prefix !== effectivePrefix)
141
104
  .map(p => p.Prefix);
142
105
  nextContinuationToken = result.NextContinuationToken;
143
- }
144
-
145
- else {
146
- let continuationToken = undefined;
147
-
148
- do {
149
- const result = await listfiles(effectivePrefix, { continuationToken, bucketName });
150
- if (result.Contents?.length) {
151
- for (const file of result.Contents) {
152
- if (!file.Key.endsWith('/')) files.push(file);
153
- }
154
- }
155
- continuationToken = result.nextToken;
156
- } while (continuationToken);
157
-
158
- if (!files.length && !search) {
159
- try {
160
- const rootResult = await listfiles('', { bucketName });
161
- if ((rootResult.Contents || []).length) {
162
- files = rootResult.Contents || [];
163
- usedPrefix = '';
164
- legacyFallback = true;
165
- }
166
- } catch (fallbackErr) {
167
- debugApi('Fallback root listing failed: %s', fallbackErr.message);
168
- }
169
- }
170
106
 
171
- if (isSearch) {
172
- const searchLower = search.trim().toLowerCase();
173
- files = files.filter(file => file.Key.toLowerCase().includes(searchLower));
174
- }
175
- }
176
-
177
- if (useOptimizedListing) {
178
- res.json({
107
+ return res.json({
179
108
  success: true,
180
109
  files,
181
110
  folders,
@@ -190,29 +119,50 @@ export async function listFiles(req, res) {
190
119
  totalFiles: -1,
191
120
  paginationType: 'continuation-token'
192
121
  });
193
- } else {
194
- const totalFiles = files.length;
195
- const totalPages = Math.max(1, Math.ceil(totalFiles / pageSize));
196
- const currentPage = Math.max(1, parseInt(page, 10) || 1);
197
- const startIndex = (currentPage - 1) * pageSize;
198
- const paginatedFiles = files.slice(startIndex, startIndex + pageSize);
199
-
200
- res.json({
201
- success: true,
202
- files: paginatedFiles,
203
- folders: [],
204
- prefix: effectivePrefix,
205
- search,
206
- currentPage,
207
- totalPages,
208
- hasNextPage: currentPage < totalPages,
209
- hasPrevPage: currentPage > 1,
210
- nextPage: currentPage + 1,
211
- prevPage: Math.max(1, currentPage - 1),
212
- totalFiles
122
+ }
123
+
124
+ let continuationToken;
125
+ do {
126
+ const result = await listfiles(effectivePrefix, { continuationToken, bucketName });
127
+ (result.Contents || []).forEach(file => {
128
+ if (!file.Key.endsWith('/')) files.push(file);
213
129
  });
130
+ continuationToken = result.nextToken;
131
+ } while (continuationToken);
132
+
133
+ if (!files.length && !search) {
134
+ try {
135
+ const rootResult = await listfiles('', { bucketName });
136
+ if (rootResult.Contents?.length) files = rootResult.Contents;
137
+ } catch (fallbackErr) {
138
+ debugApi('Fallback root listing failed: %s', fallbackErr.message);
139
+ }
214
140
  }
215
141
 
142
+ if (isSearch) {
143
+ const searchLower = search.trim().toLowerCase();
144
+ files = files.filter(file => file.Key.toLowerCase().includes(searchLower));
145
+ }
146
+
147
+ const totalFiles = files.length;
148
+ const totalPages = Math.max(1, Math.ceil(totalFiles / pageSize));
149
+ const currentPage = Math.max(1, parseInt(page, 10) || 1);
150
+ const paginatedFiles = files.slice((currentPage - 1) * pageSize, currentPage * pageSize);
151
+
152
+ res.json({
153
+ success: true,
154
+ files: paginatedFiles,
155
+ folders: [],
156
+ prefix: effectivePrefix,
157
+ search,
158
+ currentPage,
159
+ totalPages,
160
+ hasNextPage: currentPage < totalPages,
161
+ hasPrevPage: currentPage > 1,
162
+ nextPage: currentPage + 1,
163
+ prevPage: Math.max(1, currentPage - 1),
164
+ totalFiles
165
+ });
216
166
  } catch (error) {
217
167
  console.error("Error listing files:", error);
218
168
  const mapped = classifyApiError(error, 'Error listing files');
@@ -229,16 +179,12 @@ export async function listFiles(req, res) {
229
179
  export async function uploadSingleFile(req, res) {
230
180
  try {
231
181
  const bucketName = req.activeBucket;
232
- if (!req.file) {
233
- return res.status(400).json({ success: false, error: 'No file selected' });
234
- }
182
+ if (!req.file) return res.status(400).json({ success: false, error: 'No file selected' });
235
183
 
236
- const { prefix = '' } = req.body;
237
- const effectivePrefix = resolvePrefix(req, res, prefix);
184
+ const effectivePrefix = resolvePrefix(req, res, req.body.prefix || '');
238
185
  if (effectivePrefix === null) return;
239
186
 
240
187
  const key = buildKey(effectivePrefix, req.file.originalname);
241
-
242
188
  const uploadOptions = {
243
189
  metadata: {
244
190
  'original-name': req.file.originalname,
@@ -251,10 +197,7 @@ export async function uploadSingleFile(req, res) {
251
197
 
252
198
  try {
253
199
  await uploadFile(key, req.file.buffer, req.file.mimetype, uploadOptions);
254
-
255
- const folderPath = key.substring(0, key.lastIndexOf('/') + 1);
256
- await cleanupFolderMarker(folderPath, bucketName);
257
-
200
+ await cleanupFolderMarker(getFolderPath(key), bucketName);
258
201
  res.json({ success: true, message: 'File uploaded successfully', key });
259
202
  } catch (uploadError) {
260
203
  if (uploadError.message.includes('already exists')) {
@@ -278,9 +221,9 @@ export async function initiateMultipartUpload(req, res) {
278
221
  if (effectivePrefix === null) return;
279
222
 
280
223
  const key = buildKey(effectivePrefix, fileName);
281
-
282
- const exists = await fileExists(key, bucketName);
283
- if (exists) return res.status(409).json({ success: false, error: `File already exists: ${fileName}` });
224
+ if (await fileExists(key, bucketName)) {
225
+ return res.status(409).json({ success: false, error: `File already exists: ${fileName}` });
226
+ }
284
227
 
285
228
  const result = await createMultipartUpload(key, contentType, {}, bucketName);
286
229
  return res.json({ success: true, uploadId: result.uploadId, key: result.key });
@@ -292,7 +235,6 @@ export async function initiateMultipartUpload(req, res) {
292
235
 
293
236
  export async function uploadChunk(req, res) {
294
237
  try {
295
- const bucketName = req.activeBucket;
296
238
  const { uploadId, key, partNumber } = req.body;
297
239
  if (!req.file || !uploadId || !key || !partNumber) {
298
240
  return res.status(400).json({ success: false, error: 'chunk file, uploadId, key, and partNumber are required' });
@@ -302,7 +244,7 @@ export async function uploadChunk(req, res) {
302
244
  return res.status(400).json({ success: false, error: 'partNumber must be an integer between 1 and 10000' });
303
245
  }
304
246
 
305
- const result = await uploadPart(key, uploadId, part, req.file.buffer, bucketName);
247
+ const result = await uploadPart(key, uploadId, part, req.file.buffer, req.activeBucket);
306
248
  return res.json({ success: true, partNumber: result.partNumber, ETag: result.ETag });
307
249
  } catch (err) {
308
250
  console.error('[mbkbucket] upload-chunk failed:', err);
@@ -319,9 +261,7 @@ export async function completeUpload(req, res) {
319
261
  }
320
262
 
321
263
  const result = await completeMultipartUpload(key, uploadId, parts, bucketName);
322
-
323
- const folderPath = result.key.substring(0, result.key.lastIndexOf('/') + 1);
324
- await cleanupFolderMarker(folderPath, bucketName);
264
+ await cleanupFolderMarker(getFolderPath(result.key), bucketName);
325
265
 
326
266
  return res.json({ success: true, message: 'File uploaded successfully', key: result.key });
327
267
  } catch (err) {
@@ -332,11 +272,10 @@ export async function completeUpload(req, res) {
332
272
 
333
273
  export async function abortUpload(req, res) {
334
274
  try {
335
- const bucketName = req.activeBucket;
336
275
  const { uploadId, key } = req.body;
337
276
  if (!uploadId || !key) return res.status(400).json({ success: false, error: 'uploadId and key are required' });
338
277
 
339
- await abortMultipartUpload(key, uploadId, bucketName);
278
+ await abortMultipartUpload(key, uploadId, req.activeBucket);
340
279
  return res.json({ success: true, message: 'Multipart upload aborted' });
341
280
  } catch (err) {
342
281
  console.error('[mbkbucket] upload-abort failed:', err);
@@ -354,31 +293,22 @@ export async function createFolder(req, res) {
354
293
  if (effectivePrefix === null) return;
355
294
 
356
295
  const cleanName = String(folderName).replace(/^\/+/, '').replace(/\/+$/, '');
357
- let folderKey;
358
- if (effectivePrefix && !cleanName.startsWith(effectivePrefix)) {
359
- const cleanPrefix = effectivePrefix.replace(/\/+$/, '');
360
- folderKey = `${cleanPrefix}/${cleanName}/`;
361
- } else {
362
- folderKey = `${cleanName}/`;
363
- }
296
+ const cleanPrefix = effectivePrefix.replace(/\/+$/, '');
297
+ const folderKey = (effectivePrefix && !cleanName.startsWith(effectivePrefix))
298
+ ? `${cleanPrefix}/${cleanName}/`
299
+ : `${cleanName}/`;
364
300
 
365
- const markerExists = await fileExists(folderKey, bucketName);
366
- if (markerExists) return res.status(409).json({ success: false, error: 'Folder already exists' });
301
+ if (await fileExists(folderKey, bucketName)) {
302
+ return res.status(409).json({ success: false, error: 'Folder already exists' });
303
+ }
367
304
 
368
305
  const existingFiles = await listfiles(folderKey, { maxKeys: 1, bucketName });
369
- const hasContent = existingFiles.Contents && existingFiles.Contents.length > 0;
370
-
371
- if (hasContent) {
372
- return res.json({
373
- success: true,
374
- message: 'Folder already exists with content',
375
- key: folderKey,
376
- skipMarker: true
377
- });
306
+ if (existingFiles.Contents?.length) {
307
+ return res.json({ success: true, message: 'Folder already exists with content', key: folderKey, skipMarker: true });
378
308
  }
379
309
 
380
310
  await uploadFile(folderKey, Buffer.alloc(0), 'application/x-empty', {
381
- metadata: { 'folder': 'true', 'marker': 'true' },
311
+ metadata: { folder: 'true', marker: 'true' },
382
312
  bucketName
383
313
  });
384
314
 
@@ -402,7 +332,11 @@ export async function deleteItems(req, res) {
402
332
  if (!key) return res.status(400).json({ success: false, error: 'Key is required' });
403
333
 
404
334
  let keyToDelete;
405
- try { keyToDelete = ensureKeyHasAppPrefix(key); } catch (e) { return res.status(400).json({ success: false, error: e.message }); }
335
+ try {
336
+ keyToDelete = ensureKeyHasAppPrefix(key);
337
+ } catch (e) {
338
+ return res.status(400).json({ success: false, error: e.message });
339
+ }
406
340
 
407
341
  if (folder === true || String(keyToDelete).endsWith('/')) {
408
342
  await deleteFolder(keyToDelete, bucketName);
@@ -418,15 +352,12 @@ export async function deleteItems(req, res) {
418
352
  }
419
353
 
420
354
  export async function downloadFileHandler(req, res) {
421
- // Track whether this request has been aborted (client disconnected)
422
355
  let aborted = false;
423
356
  req.once('aborted', () => { aborted = true; });
424
357
  req.once('close', () => { aborted = true; });
425
358
 
426
359
  try {
427
- const bucketName = req.activeBucket;
428
- const rawKey = req.params.key;
429
- const key = Array.isArray(rawKey) ? rawKey.join('/') : (rawKey || '');
360
+ const key = normalizeKeyParam(req.params.key);
430
361
  let keyToUse;
431
362
  try {
432
363
  keyToUse = ensureKeyHasAppPrefix(key);
@@ -434,14 +365,11 @@ export async function downloadFileHandler(req, res) {
434
365
  return res.status(400).json({ success: false, error: e.message });
435
366
  }
436
367
 
437
- // If client already disconnected before we fetched, bail early
438
368
  if (aborted) return;
439
369
 
440
- const result = await downloadFile(keyToUse, { bucketName });
441
-
442
- // Re-check: client may have disconnected while waiting for S3
370
+ const result = await downloadFile(keyToUse, { bucketName: req.activeBucket });
443
371
  if (aborted) {
444
- if (result.Body && typeof result.Body.destroy === 'function') result.Body.destroy();
372
+ destroyStream(result.Body);
445
373
  return;
446
374
  }
447
375
 
@@ -449,50 +377,31 @@ export async function downloadFileHandler(req, res) {
449
377
  const contentType = result.ContentType || 'application/octet-stream';
450
378
  const contentLength = result.ContentLength;
451
379
 
452
- // Set headers BEFORE piping — this allows the browser to show filename
453
- // and progress bar even before the first byte arrives
454
380
  res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
455
381
  res.setHeader('Content-Type', contentType);
456
382
  if (contentLength) res.setHeader('Content-Length', contentLength);
457
383
  if (result.ETag) res.setHeader('ETag', result.ETag);
458
384
  if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
459
385
 
460
- const typeLower = String(contentType).toLowerCase();
461
- const isSensitiveText = typeLower.startsWith('text/')
462
- || typeLower.includes('json')
463
- || typeLower.includes('xml')
464
- || typeLower.includes('javascript')
465
- || typeLower.includes('typescript');
386
+ const isSensitive = isSensitiveTextType(contentType);
466
387
  const isLargeFile = Number(contentLength || 0) > 50 * 1024 * 1024;
388
+ res.setHeader('Cache-Control', isSensitive ? 'private, no-store' : isLargeFile ? 'private, max-age=600, must-revalidate' : 'private, max-age=1800, must-revalidate');
467
389
 
468
- if (isSensitiveText) {
469
- res.setHeader('Cache-Control', 'private, no-store');
470
- } else if (isLargeFile) {
471
- res.setHeader('Cache-Control', 'private, max-age=600, must-revalidate');
472
- } else {
473
- res.setHeader('Cache-Control', 'private, max-age=1800, must-revalidate');
474
- }
475
-
476
- // Use pipeline for proper backpressure + cleanup. On abort, the
477
- // pipeline will be rejected and we clean up the S3 stream.
478
390
  try {
479
391
  await pipeline(result.Body, res);
480
392
  } catch (pipeErr) {
481
- // Aborted by client — expected, not an error
482
393
  if (aborted) {
483
- if (result.Body && typeof result.Body.destroy === 'function') result.Body.destroy();
394
+ destroyStream(result.Body);
484
395
  return;
485
396
  }
486
397
  throw pipeErr;
487
398
  }
488
399
  } catch (error) {
489
- // Don't try to send error response if client already disconnected
490
400
  if (aborted) return;
491
-
492
401
  console.error("Error downloading file:", error);
493
- if (error.message && error.message.includes('File not found')) {
402
+ if (error.message?.includes('File not found')) {
494
403
  res.status(404).json({ message: "File not found", key: req.params.key });
495
- } else if (error.message && error.message.includes('Access denied')) {
404
+ } else if (error.message?.includes('Access denied')) {
496
405
  res.status(403).json({ message: "Access denied" });
497
406
  } else if (!res.headersSent) {
498
407
  res.status(500).json({ message: "Download failed", error: error.message });
@@ -1,3 +1,4 @@
1
+ import fs from "fs";
1
2
  import path from "path";
2
3
  import { resolveBucketName, getAvailableBucketNames } from "../services/s3.service.js";
3
4
  import { renderPage, mbkautheVar } from "mbkauthe";
@@ -11,49 +12,37 @@ export function renderBucketDashboard(req, res) {
11
12
  const configuredDefaultBucket = mbkautheVar?.bucket;
12
13
  const isPortal = mbkautheVar?.APP_NAME === "portal";
13
14
  const bucketfilePath = isPortal ? "bucketportal.handlebars" : "bucket.handlebars";
14
- const portalLayout = isPortal ? path.resolve(process.cwd(), "views", "layouts", "main") : false;
15
+
16
+ const mainLayoutPath = path.resolve(process.cwd(), "views", "layouts", "main");
17
+ const hasMainLayout = fs.existsSync(`${mainLayoutPath}.handlebars`) || fs.existsSync(mainLayoutPath);
18
+ const portalLayout = (isPortal && hasMainLayout) ? mainLayoutPath : false;
15
19
 
16
20
  let selectedBucket;
21
+ let error = req.query.error;
17
22
 
18
23
  try {
19
24
  selectedBucket = resolveBucketName(req.query.bucket);
20
25
  } catch (err) {
21
- const fallbackSelected = mbkautheVar?.bucket;
22
- const bucketOptions = allBuckets.map((name) => ({
23
- name,
24
- isSelected: name === fallbackSelected,
25
- isDefault: name === configuredDefaultBucket
26
- }));
27
- return renderPage(req, res, bucketfilePath, isPortal, {
28
- page: "Admin Bucket",
29
- helpers: commonHandlebarsHelpers,
30
- ...(isPortal ? { layout: portalLayout } : {}),
31
- bucketvar: mbkautheVar?.bucket,
32
- selectedBucket: fallbackSelected,
33
- bucketOptions,
34
- APP_NAME: mbkautheVar?.APP_NAME,
35
- message: req.query.message,
36
- error: req.query.error || err.message,
37
- availableBuckets: allBuckets
38
- });
26
+ selectedBucket = mbkautheVar?.bucket;
27
+ error ||= err.message;
39
28
  }
40
29
 
41
- const bucketOptions = allBuckets.map((name) => ({
30
+ const bucketOptions = allBuckets.map(name => ({
42
31
  name,
43
32
  isSelected: name === selectedBucket,
44
33
  isDefault: name === configuredDefaultBucket
45
34
  }));
46
35
 
47
- renderPage(req, res, bucketfilePath, isPortal, {
36
+ renderPage(req, res, bucketfilePath, portalLayout ? true : false, {
48
37
  page: "Admin Bucket",
49
38
  helpers: commonHandlebarsHelpers,
50
- ...(isPortal ? { layout: portalLayout } : {}),
39
+ ...(portalLayout ? { layout: portalLayout } : { layout: false }),
51
40
  bucketvar: selectedBucket,
52
41
  selectedBucket,
53
42
  bucketOptions,
54
43
  APP_NAME: mbkautheVar?.APP_NAME,
55
44
  message: req.query.message,
56
- error: req.query.error,
45
+ error,
57
46
  availableBuckets: allBuckets
58
47
  });
59
48
  }
@@ -1,37 +1,34 @@
1
1
  import { renderPage, mbkautheVar } from "mbkauthe";
2
2
  import { packageJson, getLatestVersion } from "../config/index.js";
3
3
 
4
- /**
5
- * Render the mbkbucket info page (Handlebars).
6
- */
7
- export async function infoPage(req, res) {
4
+ async function getInfoData() {
8
5
  let latestVersion = 'unknown';
9
6
  try {
10
- latestVersion = await getLatestVersion();
7
+ latestVersion = (await getLatestVersion()) || 'unknown';
11
8
  } catch (err) {
12
- console.error("[mbkauthe] Error fetching package-lock.json:", err);
9
+ console.error("[mbkbucket] Error fetching latest version:", err);
13
10
  }
14
- renderPage(req, res, "mbkbucket_info.handlebars", false, {
15
- page: "MBK Bucket Info",
11
+ return {
16
12
  CurrentVersion: packageJson.version,
17
13
  latestVersion,
18
14
  APP_NAME: mbkautheVar?.APP_NAME
15
+ };
16
+ }
17
+
18
+ /**
19
+ * Render the mbkbucket info page (Handlebars).
20
+ */
21
+ export async function infoPage(req, res) {
22
+ const data = await getInfoData();
23
+ renderPage(req, res, "mbkbucket_info.handlebars", false, {
24
+ page: "MBK Bucket Info",
25
+ ...data
19
26
  });
20
27
  }
21
28
 
22
29
  /**
23
30
  * Return bucket info as JSON.
24
31
  */
25
- export async function infoJson(req, res) {
26
- let latestVersion = 'unknown';
27
- try {
28
- latestVersion = await getLatestVersion();
29
- } catch (err) {
30
- console.error("[mbkauthe] Error fetching package-lock.json:", err);
31
- }
32
- res.json({
33
- CurrentVersion: packageJson.version,
34
- latestVersion,
35
- APP_NAME: mbkautheVar?.APP_NAME
36
- });
32
+ export async function infoJson(_req, res) {
33
+ res.json(await getInfoData());
37
34
  }