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.
- package/.env.example +2 -2
- package/README.md +72 -0
- package/index.d.ts +87 -49
- package/index.js +15 -13
- package/lib/config/index.js +9 -89
- package/lib/config/validation.js +86 -0
- package/lib/routes/api.js +139 -45
- 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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
260
|
+
const mapped = classifyApiError(error, 'Error listing files');
|
|
261
|
+
return res.status(mapped.status).json({
|
|
197
262
|
success: false,
|
|
198
|
-
|
|
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
|
|
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
|
|
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'),
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
461
|
-
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
|
+
}
|
|
462
556
|
|
|
463
557
|
// Proper stream error handling
|
|
464
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);
|