mbkbucket 1.5.1 → 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.
- package/.env.example +2 -2
- package/README.md +72 -0
- package/index.d.ts +87 -49
- package/index.js +11 -11
- package/lib/config/index.js +9 -89
- package/lib/config/validation.js +86 -0
- package/lib/routes/api.js +112 -43
- package/lib/routes/index.js +70 -1
- package/lib/routes/view.js +71 -18
- package/lib/s3.js +64 -32
- package/package.json +2 -2
- package/public/bucketadmin.css +447 -17
- package/public/bucketadmin.js +272 -82
- package/test/config-validation.test.js +116 -0
- package/views/bucket.handlebars +66 -31
- package/views/bucketadmincontent.handlebars +30 -9
- package/views/bucketportal.handlebars +1 -0
|
@@ -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
|
@@ -5,6 +5,47 @@ import multer from "multer";
|
|
|
5
5
|
|
|
6
6
|
const router = express.Router();
|
|
7
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
|
+
|
|
8
49
|
// This is the maximum size we allow per chunk upload. It should be >=
|
|
9
50
|
// the client chunk size (5MB) and leave room for any encoding overhead.
|
|
10
51
|
// If you need to support larger chunks, increase this accordingly.
|
|
@@ -35,6 +76,7 @@ const uploadChunk = (req, res, next) => {
|
|
|
35
76
|
router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
36
77
|
try {
|
|
37
78
|
const { olderThanDays = 7, prefix = '' } = req.body;
|
|
79
|
+
const bucketName = req.activeBucket;
|
|
38
80
|
|
|
39
81
|
// Validate olderThanDays
|
|
40
82
|
const days = parseInt(olderThanDays, 10);
|
|
@@ -43,7 +85,7 @@ router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async
|
|
|
43
85
|
}
|
|
44
86
|
|
|
45
87
|
console.log(`[mbkbucket] Starting cleanup of incomplete uploads older than ${days} days`);
|
|
46
|
-
const result = await cleanupIncompleteMultipartUploads(days, prefix);
|
|
88
|
+
const result = await cleanupIncompleteMultipartUploads(days, prefix, bucketName);
|
|
47
89
|
|
|
48
90
|
res.json({
|
|
49
91
|
success: true,
|
|
@@ -52,10 +94,7 @@ router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async
|
|
|
52
94
|
});
|
|
53
95
|
} catch (error) {
|
|
54
96
|
console.error('[mbkbucket] Cleanup failed:', error);
|
|
55
|
-
res
|
|
56
|
-
success: false,
|
|
57
|
-
error: error.message
|
|
58
|
-
});
|
|
97
|
+
return sendApiError(res, error, 'Cleanup failed');
|
|
59
98
|
}
|
|
60
99
|
});
|
|
61
100
|
|
|
@@ -63,7 +102,8 @@ router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async
|
|
|
63
102
|
router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
64
103
|
try {
|
|
65
104
|
const { prefix = '' } = req.query;
|
|
66
|
-
const
|
|
105
|
+
const bucketName = req.activeBucket;
|
|
106
|
+
const uploads = await listIncompleteMultipartUploads(prefix, bucketName);
|
|
67
107
|
|
|
68
108
|
res.json({
|
|
69
109
|
success: true,
|
|
@@ -77,10 +117,7 @@ router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), asyn
|
|
|
77
117
|
});
|
|
78
118
|
} catch (error) {
|
|
79
119
|
console.error('[mbkbucket] Failed to list incomplete uploads:', error);
|
|
80
|
-
res
|
|
81
|
-
success: false,
|
|
82
|
-
error: error.message
|
|
83
|
-
});
|
|
120
|
+
return sendApiError(res, error, 'Failed to list incomplete uploads');
|
|
84
121
|
}
|
|
85
122
|
});
|
|
86
123
|
|
|
@@ -88,6 +125,7 @@ router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), asyn
|
|
|
88
125
|
router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
89
126
|
const { prefix = '', page = '1', search = '', recursive = 'true', token = '' } = req.query;
|
|
90
127
|
const pageSize = 100;
|
|
128
|
+
const bucketName = req.activeBucket;
|
|
91
129
|
let effectivePrefix;
|
|
92
130
|
try {
|
|
93
131
|
effectivePrefix = ensurePrefix(prefix);
|
|
@@ -124,7 +162,8 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
124
162
|
const result = await listfiles(listPrefix, {
|
|
125
163
|
continuationToken: token || undefined, // Use token from query if provided
|
|
126
164
|
delimiter: '/',
|
|
127
|
-
maxKeys: 1000 // Fetch a healthy batch.
|
|
165
|
+
maxKeys: 1000, // Fetch a healthy batch.
|
|
166
|
+
bucketName
|
|
128
167
|
});
|
|
129
168
|
|
|
130
169
|
files = result.Contents || [];
|
|
@@ -141,7 +180,7 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
141
180
|
|
|
142
181
|
// Fetch all files recursively (handling S3 pagination)
|
|
143
182
|
do {
|
|
144
|
-
const result = await listfiles(effectivePrefix, { continuationToken });
|
|
183
|
+
const result = await listfiles(effectivePrefix, { continuationToken, bucketName });
|
|
145
184
|
if (result.Contents) {
|
|
146
185
|
// Filter out folder marker objects (zero-byte files ending with /)
|
|
147
186
|
const actualFiles = result.Contents.filter(f => !f.Key.endsWith('/'));
|
|
@@ -153,7 +192,7 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
153
192
|
// If no files found under the app prefixed path, try root as a legacy fallback
|
|
154
193
|
if (!files.length && !search) {
|
|
155
194
|
try {
|
|
156
|
-
const rootResult = await listfiles('');
|
|
195
|
+
const rootResult = await listfiles('', { bucketName });
|
|
157
196
|
if ((rootResult.Contents || []).length) {
|
|
158
197
|
files = rootResult.Contents || [];
|
|
159
198
|
usedPrefix = '';
|
|
@@ -218,9 +257,11 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
218
257
|
|
|
219
258
|
} catch (error) {
|
|
220
259
|
console.error("Error listing files:", error);
|
|
221
|
-
|
|
260
|
+
const mapped = classifyApiError(error, 'Error listing files');
|
|
261
|
+
return res.status(mapped.status).json({
|
|
222
262
|
success: false,
|
|
223
|
-
|
|
263
|
+
code: mapped.code,
|
|
264
|
+
error: mapped.message,
|
|
224
265
|
files: [],
|
|
225
266
|
totalFiles: 0
|
|
226
267
|
});
|
|
@@ -230,6 +271,7 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
230
271
|
// Standard single-file upload
|
|
231
272
|
router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file'), async (req, res) => {
|
|
232
273
|
try {
|
|
274
|
+
const bucketName = req.activeBucket;
|
|
233
275
|
if (!req.file) {
|
|
234
276
|
return res.status(400).json({ success: false, error: 'No file selected' });
|
|
235
277
|
}
|
|
@@ -250,6 +292,7 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
|
|
|
250
292
|
'upload-source': 'web-portal',
|
|
251
293
|
'user-agent': req.headers['user-agent'] || 'unknown'
|
|
252
294
|
},
|
|
295
|
+
bucketName,
|
|
253
296
|
preventOverwrite: true // Use atomic operation to prevent race conditions
|
|
254
297
|
};
|
|
255
298
|
|
|
@@ -262,12 +305,12 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
|
|
|
262
305
|
if (folderPath) {
|
|
263
306
|
const folderMarkerKey = folderPath; // Already ends with /
|
|
264
307
|
try {
|
|
265
|
-
const markerExists = await fileExists(folderMarkerKey);
|
|
308
|
+
const markerExists = await fileExists(folderMarkerKey, bucketName);
|
|
266
309
|
if (markerExists) {
|
|
267
310
|
// Check if this is actually a folder marker (zero-byte file)
|
|
268
|
-
const metadata = await getFileMetadata(folderMarkerKey);
|
|
311
|
+
const metadata = await getFileMetadata(folderMarkerKey, bucketName);
|
|
269
312
|
if (metadata.ContentLength === 0 || metadata.Metadata?.marker === 'true') {
|
|
270
|
-
await deleteFile(folderMarkerKey);
|
|
313
|
+
await deleteFile(folderMarkerKey, bucketName);
|
|
271
314
|
console.log(`[mbkbucket] Cleaned up folder marker: ${folderMarkerKey}`);
|
|
272
315
|
}
|
|
273
316
|
}
|
|
@@ -287,7 +330,7 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
|
|
|
287
330
|
}
|
|
288
331
|
} catch (error) {
|
|
289
332
|
console.error("Error uploading file:", error);
|
|
290
|
-
res
|
|
333
|
+
return sendApiError(res, error, 'Upload failed');
|
|
291
334
|
}
|
|
292
335
|
});
|
|
293
336
|
|
|
@@ -296,6 +339,7 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
|
|
|
296
339
|
// Returns: { uploadId, key }
|
|
297
340
|
router.post('/upload-init', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
298
341
|
try {
|
|
342
|
+
const bucketName = req.activeBucket;
|
|
299
343
|
const { fileName, prefix = '', contentType = 'application/octet-stream' } = req.body;
|
|
300
344
|
if (!fileName) return res.status(400).json({ success: false, error: 'fileName is required' });
|
|
301
345
|
|
|
@@ -306,14 +350,14 @@ router.post('/upload-init', validateSessionAndRole('SuperAdmin'), async (req, re
|
|
|
306
350
|
// Check if file exists before initiating multipart upload
|
|
307
351
|
// Note: This is still slightly racy, but multipart uploads take time so collision risk is lower
|
|
308
352
|
// A complete solution would require server-side locking or unique upload IDs
|
|
309
|
-
const exists = await fileExists(key);
|
|
353
|
+
const exists = await fileExists(key, bucketName);
|
|
310
354
|
if (exists) return res.status(409).json({ success: false, error: `File already exists: ${fileName}` });
|
|
311
355
|
|
|
312
|
-
const result = await createMultipartUpload(key, contentType);
|
|
356
|
+
const result = await createMultipartUpload(key, contentType, {}, bucketName);
|
|
313
357
|
return res.json({ success: true, uploadId: result.uploadId, key: result.key });
|
|
314
358
|
} catch (err) {
|
|
315
359
|
console.error('[mbkbucket] upload-init failed:', err);
|
|
316
|
-
return res
|
|
360
|
+
return sendApiError(res, err, 'upload-init failed');
|
|
317
361
|
}
|
|
318
362
|
});
|
|
319
363
|
|
|
@@ -322,6 +366,7 @@ router.post('/upload-init', validateSessionAndRole('SuperAdmin'), async (req, re
|
|
|
322
366
|
// Returns: { partNumber, ETag }
|
|
323
367
|
router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), uploadChunk, async (req, res) => {
|
|
324
368
|
try {
|
|
369
|
+
const bucketName = req.activeBucket;
|
|
325
370
|
const { uploadId, key, partNumber } = req.body;
|
|
326
371
|
if (!req.file || !uploadId || !key || !partNumber) {
|
|
327
372
|
return res.status(400).json({ success: false, error: 'chunk file, uploadId, key, and partNumber are required' });
|
|
@@ -331,11 +376,11 @@ router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), uploadChunk,
|
|
|
331
376
|
return res.status(400).json({ success: false, error: 'partNumber must be an integer between 1 and 10000' });
|
|
332
377
|
}
|
|
333
378
|
|
|
334
|
-
const result = await uploadPart(key, uploadId, part, req.file.buffer);
|
|
379
|
+
const result = await uploadPart(key, uploadId, part, req.file.buffer, bucketName);
|
|
335
380
|
return res.json({ success: true, partNumber: result.partNumber, ETag: result.ETag });
|
|
336
381
|
} catch (err) {
|
|
337
382
|
console.error('[mbkbucket] upload-chunk failed:', err);
|
|
338
|
-
return res
|
|
383
|
+
return sendApiError(res, err, 'upload-chunk failed');
|
|
339
384
|
}
|
|
340
385
|
});
|
|
341
386
|
|
|
@@ -344,23 +389,24 @@ router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), uploadChunk,
|
|
|
344
389
|
// Returns: { key }
|
|
345
390
|
router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
346
391
|
try {
|
|
392
|
+
const bucketName = req.activeBucket;
|
|
347
393
|
const { uploadId, key, parts } = req.body;
|
|
348
394
|
if (!uploadId || !key || !Array.isArray(parts) || !parts.length) {
|
|
349
395
|
return res.status(400).json({ success: false, error: 'uploadId, key, and parts array are required' });
|
|
350
396
|
}
|
|
351
397
|
|
|
352
|
-
const result = await completeMultipartUpload(key, uploadId, parts);
|
|
398
|
+
const result = await completeMultipartUpload(key, uploadId, parts, bucketName);
|
|
353
399
|
|
|
354
400
|
// Clean up folder marker if it exists in the parent directory
|
|
355
401
|
const folderPath = result.key.substring(0, result.key.lastIndexOf('/') + 1);
|
|
356
402
|
if (folderPath) {
|
|
357
403
|
const folderMarkerKey = folderPath;
|
|
358
404
|
try {
|
|
359
|
-
const markerExists = await fileExists(folderMarkerKey);
|
|
405
|
+
const markerExists = await fileExists(folderMarkerKey, bucketName);
|
|
360
406
|
if (markerExists) {
|
|
361
|
-
const metadata = await getFileMetadata(folderMarkerKey);
|
|
407
|
+
const metadata = await getFileMetadata(folderMarkerKey, bucketName);
|
|
362
408
|
if (metadata.ContentLength === 0 || metadata.Metadata?.marker === 'true') {
|
|
363
|
-
await deleteFile(folderMarkerKey);
|
|
409
|
+
await deleteFile(folderMarkerKey, bucketName);
|
|
364
410
|
console.log(`[mbkbucket] Cleaned up folder marker: ${folderMarkerKey}`);
|
|
365
411
|
}
|
|
366
412
|
}
|
|
@@ -372,7 +418,7 @@ router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req
|
|
|
372
418
|
return res.json({ success: true, message: 'File uploaded successfully', key: result.key });
|
|
373
419
|
} catch (err) {
|
|
374
420
|
console.error('[mbkbucket] upload-complete failed:', err);
|
|
375
|
-
return res
|
|
421
|
+
return sendApiError(res, err, 'upload-complete failed');
|
|
376
422
|
}
|
|
377
423
|
});
|
|
378
424
|
|
|
@@ -380,20 +426,22 @@ router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req
|
|
|
380
426
|
// Body: { uploadId, key }
|
|
381
427
|
router.post('/upload-abort', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
382
428
|
try {
|
|
429
|
+
const bucketName = req.activeBucket;
|
|
383
430
|
const { uploadId, key } = req.body;
|
|
384
431
|
if (!uploadId || !key) return res.status(400).json({ success: false, error: 'uploadId and key are required' });
|
|
385
432
|
|
|
386
|
-
await abortMultipartUpload(key, uploadId);
|
|
433
|
+
await abortMultipartUpload(key, uploadId, bucketName);
|
|
387
434
|
return res.json({ success: true, message: 'Multipart upload aborted' });
|
|
388
435
|
} catch (err) {
|
|
389
436
|
console.error('[mbkbucket] upload-abort failed:', err);
|
|
390
|
-
return res
|
|
437
|
+
return sendApiError(res, err, 'upload-abort failed');
|
|
391
438
|
}
|
|
392
439
|
});
|
|
393
440
|
|
|
394
441
|
// Create folder (zero-byte object with trailing slash)
|
|
395
442
|
router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
396
443
|
try {
|
|
444
|
+
const bucketName = req.activeBucket;
|
|
397
445
|
const { prefix = '', folderName } = req.body;
|
|
398
446
|
if (!folderName) return res.status(400).json({ success: false, error: 'folderName is required' });
|
|
399
447
|
|
|
@@ -403,11 +451,11 @@ router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req,
|
|
|
403
451
|
const folderKey = `${effectivePrefix.replace(/\/+$/, '')}/${folderName}/`;
|
|
404
452
|
|
|
405
453
|
// Check if folder marker already exists
|
|
406
|
-
const markerExists = await fileExists(folderKey);
|
|
454
|
+
const markerExists = await fileExists(folderKey, bucketName);
|
|
407
455
|
if (markerExists) return res.status(409).json({ success: false, error: 'Folder already exists' });
|
|
408
456
|
|
|
409
457
|
// Check if any files already exist under this prefix (folder already has content)
|
|
410
|
-
const existingFiles = await listfiles(folderKey, { maxKeys: 1 });
|
|
458
|
+
const existingFiles = await listfiles(folderKey, { maxKeys: 1, bucketName });
|
|
411
459
|
const hasContent = existingFiles.Contents && existingFiles.Contents.length > 0;
|
|
412
460
|
|
|
413
461
|
if (hasContent) {
|
|
@@ -422,23 +470,25 @@ router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req,
|
|
|
422
470
|
|
|
423
471
|
// Create zero-byte object to represent empty folder
|
|
424
472
|
await uploadFile(folderKey, Buffer.alloc(0), 'application/x-empty', {
|
|
425
|
-
metadata: { 'folder': 'true', 'marker': 'true' }
|
|
473
|
+
metadata: { 'folder': 'true', 'marker': 'true' },
|
|
474
|
+
bucketName
|
|
426
475
|
});
|
|
427
476
|
|
|
428
477
|
return res.json({ success: true, message: 'Folder created', key: folderKey });
|
|
429
478
|
} catch (err) {
|
|
430
479
|
console.error('Create folder failed:', err);
|
|
431
|
-
return res
|
|
480
|
+
return sendApiError(res, err, 'Create folder failed');
|
|
432
481
|
}
|
|
433
482
|
});
|
|
434
483
|
|
|
435
484
|
router.post('/delete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
436
485
|
try {
|
|
486
|
+
const bucketName = req.activeBucket;
|
|
437
487
|
const { key, keys, folder } = req.body;
|
|
438
488
|
|
|
439
489
|
if (keys && Array.isArray(keys) && keys.length) {
|
|
440
490
|
// Bulk delete
|
|
441
|
-
await deleteFiles(keys);
|
|
491
|
+
await deleteFiles(keys, bucketName);
|
|
442
492
|
return res.json({ success: true, message: 'Files deleted successfully' });
|
|
443
493
|
}
|
|
444
494
|
|
|
@@ -449,21 +499,22 @@ router.post('/delete', validateSessionAndRole('SuperAdmin'), async (req, res) =>
|
|
|
449
499
|
|
|
450
500
|
// If caller indicates it's a folder (or key ends with '/'), delete all objects under the prefix
|
|
451
501
|
if (folder === true || String(keyToDelete).endsWith('/')) {
|
|
452
|
-
await deleteFolder(keyToDelete);
|
|
502
|
+
await deleteFolder(keyToDelete, bucketName);
|
|
453
503
|
return res.json({ success: true, message: 'Folder deleted successfully' });
|
|
454
504
|
}
|
|
455
505
|
|
|
456
|
-
await deleteFile(keyToDelete);
|
|
506
|
+
await deleteFile(keyToDelete, bucketName);
|
|
457
507
|
res.json({ success: true, message: 'File deleted successfully' });
|
|
458
508
|
} catch (error) {
|
|
459
509
|
console.error("Error deleting file:", error);
|
|
460
|
-
res
|
|
510
|
+
return sendApiError(res, error, 'Delete failed');
|
|
461
511
|
}
|
|
462
512
|
});
|
|
463
513
|
|
|
464
514
|
router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
465
515
|
let stream = null;
|
|
466
516
|
try {
|
|
517
|
+
const bucketName = req.activeBucket;
|
|
467
518
|
const key = req.params.key;
|
|
468
519
|
let keyToUse;
|
|
469
520
|
try {
|
|
@@ -471,19 +522,37 @@ router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req
|
|
|
471
522
|
} catch (e) {
|
|
472
523
|
return res.status(400).json({ success: false, error: e.message });
|
|
473
524
|
}
|
|
474
|
-
const result = await downloadFile(keyToUse);
|
|
525
|
+
const result = await downloadFile(keyToUse, { bucketName });
|
|
475
526
|
stream = result.Body;
|
|
476
527
|
|
|
477
528
|
res.setHeader('Content-Disposition', `attachment; filename="${keyToUse.split('/').pop()}"`);
|
|
478
|
-
|
|
529
|
+
const contentType = result.ContentType || 'application/octet-stream';
|
|
530
|
+
res.setHeader('Content-Type', contentType);
|
|
479
531
|
|
|
480
532
|
// Set Content-Length header for proper download progress indication
|
|
481
533
|
if (result.ContentLength) {
|
|
482
534
|
res.setHeader('Content-Length', result.ContentLength);
|
|
483
535
|
}
|
|
484
536
|
|
|
485
|
-
|
|
486
|
-
res.setHeader('
|
|
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
|
+
}
|
|
487
556
|
|
|
488
557
|
// Proper stream error handling
|
|
489
558
|
stream.on('error', (err) => {
|
package/lib/routes/index.js
CHANGED
|
@@ -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
|
-
|
|
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);
|