mbkbucket 1.5.1 → 2.1.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 +20 -20
- package/lib/config/index.js +34 -98
- package/lib/config/validation.js +89 -0
- package/lib/debug.js +12 -0
- package/lib/routes/api.js +147 -88
- package/lib/routes/index.js +70 -1
- package/lib/routes/view.js +108 -35
- package/lib/s3.js +102 -57
- package/package.json +6 -5
- package/public/bucketadmin.css +571 -18
- package/public/bucketadmin.js +431 -150
- package/test/config-validation.test.js +116 -0
- package/views/bucket.handlebars +66 -31
- package/views/bucketadmincontent.handlebars +40 -19
- package/views/bucketportal.handlebars +1 -0
package/lib/routes/api.js
CHANGED
|
@@ -2,12 +2,60 @@ import express from "express";
|
|
|
2
2
|
import { validateSessionAndRole, } from "mbkauthe";
|
|
3
3
|
import { listfiles, uploadFile, deleteFile, deleteFiles, deleteFolder, downloadFile, getFileMetadata, fileExists, ensureKeyHasAppPrefix, ensurePrefix, getAppName, createMultipartUpload, uploadPart, completeMultipartUpload, abortMultipartUpload, listIncompleteMultipartUploads, cleanupIncompleteMultipartUploads } from "../s3.js";
|
|
4
4
|
import multer from "multer";
|
|
5
|
+
import { createLogger } from "../debug.js";
|
|
5
6
|
|
|
6
7
|
const router = express.Router();
|
|
8
|
+
const debugApi = createLogger('api');
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
function classifyApiError(error, fallbackMessage = 'Request failed') {
|
|
11
|
+
const message = String(error?.message || fallbackMessage);
|
|
12
|
+
const lower = message.toLowerCase();
|
|
13
|
+
|
|
14
|
+
if (lower.includes('bucketconnection environment variable is not set')) {
|
|
15
|
+
return { status: 503, code: 'BUCKET_CONFIG_ERROR', message };
|
|
16
|
+
}
|
|
17
|
+
if (lower.includes('no bucket selected') || lower.includes('not found in bucketconnection')) {
|
|
18
|
+
return { status: 400, code: 'INVALID_BUCKET', message };
|
|
19
|
+
}
|
|
20
|
+
if (lower.includes('already exists')) {
|
|
21
|
+
return { status: 409, code: 'CONFLICT', message };
|
|
22
|
+
}
|
|
23
|
+
if (lower.includes('not found')) {
|
|
24
|
+
return { status: 404, code: 'NOT_FOUND', message };
|
|
25
|
+
}
|
|
26
|
+
if (lower.includes('access denied')) {
|
|
27
|
+
return { status: 403, code: 'ACCESS_DENIED', message };
|
|
28
|
+
}
|
|
29
|
+
if (lower.includes('required') || lower.includes('invalid') || lower.includes('must be')) {
|
|
30
|
+
return { status: 400, code: 'VALIDATION_ERROR', message };
|
|
31
|
+
}
|
|
32
|
+
return { status: 500, code: 'INTERNAL_ERROR', message };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sendApiError(res, error, fallbackMessage = 'Request failed') {
|
|
36
|
+
const mapped = classifyApiError(error, fallbackMessage);
|
|
37
|
+
return res.status(mapped.status).json({ success: false, code: mapped.code, error: mapped.message });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getBaseName(path = '') {
|
|
41
|
+
const value = String(path || '');
|
|
42
|
+
const lastSlashIndex = value.lastIndexOf('/');
|
|
43
|
+
return lastSlashIndex === -1 ? value : value.substring(lastSlashIndex + 1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Central bucket guard for all API routes under /mbkbucket
|
|
47
|
+
router.use((req, res, next) => {
|
|
48
|
+
if (req.bucketResolveError) {
|
|
49
|
+
return sendApiError(res, req.bucketResolveError, 'Invalid bucket selection');
|
|
50
|
+
}
|
|
51
|
+
if (!req.activeBucket) {
|
|
52
|
+
return sendApiError(res, new Error('No bucket selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.'));
|
|
53
|
+
}
|
|
54
|
+
next();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// This is the maximum size we allow per file part. The dashboard uses the same
|
|
58
|
+
// size as its single-upload threshold and multipart chunk size.
|
|
11
59
|
const MAX_CHUNK_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
12
60
|
|
|
13
61
|
const upload = multer({
|
|
@@ -31,10 +79,25 @@ const uploadChunk = (req, res, next) => {
|
|
|
31
79
|
});
|
|
32
80
|
};
|
|
33
81
|
|
|
82
|
+
async function cleanupFolderMarker(folderPath, bucketName) {
|
|
83
|
+
if (!folderPath) return;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const metadata = await getFileMetadata(folderPath, bucketName);
|
|
87
|
+
if (metadata.exists && (metadata.ContentLength === 0 || metadata.Metadata?.marker === 'true')) {
|
|
88
|
+
await deleteFile(folderPath, bucketName);
|
|
89
|
+
debugApi('Cleaned up folder marker: %s', folderPath);
|
|
90
|
+
}
|
|
91
|
+
} catch (cleanupErr) {
|
|
92
|
+
debugApi('Failed to clean up folder marker: %s', cleanupErr.message);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
34
96
|
// API endpoint for multipart upload cleanup
|
|
35
97
|
router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
36
98
|
try {
|
|
37
99
|
const { olderThanDays = 7, prefix = '' } = req.body;
|
|
100
|
+
const bucketName = req.activeBucket;
|
|
38
101
|
|
|
39
102
|
// Validate olderThanDays
|
|
40
103
|
const days = parseInt(olderThanDays, 10);
|
|
@@ -42,8 +105,8 @@ router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async
|
|
|
42
105
|
return res.status(400).json({ success: false, error: 'olderThanDays must be a positive integer' });
|
|
43
106
|
}
|
|
44
107
|
|
|
45
|
-
|
|
46
|
-
const result = await cleanupIncompleteMultipartUploads(days, prefix);
|
|
108
|
+
debugApi('Starting cleanup of incomplete uploads older than %s days', days);
|
|
109
|
+
const result = await cleanupIncompleteMultipartUploads(days, prefix, bucketName);
|
|
47
110
|
|
|
48
111
|
res.json({
|
|
49
112
|
success: true,
|
|
@@ -52,10 +115,7 @@ router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async
|
|
|
52
115
|
});
|
|
53
116
|
} catch (error) {
|
|
54
117
|
console.error('[mbkbucket] Cleanup failed:', error);
|
|
55
|
-
res
|
|
56
|
-
success: false,
|
|
57
|
-
error: error.message
|
|
58
|
-
});
|
|
118
|
+
return sendApiError(res, error, 'Cleanup failed');
|
|
59
119
|
}
|
|
60
120
|
});
|
|
61
121
|
|
|
@@ -63,7 +123,8 @@ router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async
|
|
|
63
123
|
router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
64
124
|
try {
|
|
65
125
|
const { prefix = '' } = req.query;
|
|
66
|
-
const
|
|
126
|
+
const bucketName = req.activeBucket;
|
|
127
|
+
const uploads = await listIncompleteMultipartUploads(prefix, bucketName);
|
|
67
128
|
|
|
68
129
|
res.json({
|
|
69
130
|
success: true,
|
|
@@ -77,10 +138,7 @@ router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), asyn
|
|
|
77
138
|
});
|
|
78
139
|
} catch (error) {
|
|
79
140
|
console.error('[mbkbucket] Failed to list incomplete uploads:', error);
|
|
80
|
-
res
|
|
81
|
-
success: false,
|
|
82
|
-
error: error.message
|
|
83
|
-
});
|
|
141
|
+
return sendApiError(res, error, 'Failed to list incomplete uploads');
|
|
84
142
|
}
|
|
85
143
|
});
|
|
86
144
|
|
|
@@ -88,6 +146,7 @@ router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), asyn
|
|
|
88
146
|
router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
89
147
|
const { prefix = '', page = '1', search = '', recursive = 'true', token = '' } = req.query;
|
|
90
148
|
const pageSize = 100;
|
|
149
|
+
const bucketName = req.activeBucket;
|
|
91
150
|
let effectivePrefix;
|
|
92
151
|
try {
|
|
93
152
|
effectivePrefix = ensurePrefix(prefix);
|
|
@@ -124,14 +183,16 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
124
183
|
const result = await listfiles(listPrefix, {
|
|
125
184
|
continuationToken: token || undefined, // Use token from query if provided
|
|
126
185
|
delimiter: '/',
|
|
127
|
-
maxKeys: 1000 // Fetch a healthy batch.
|
|
186
|
+
maxKeys: 1000, // Fetch a healthy batch.
|
|
187
|
+
bucketName
|
|
128
188
|
});
|
|
129
189
|
|
|
130
|
-
files = result.Contents || [];
|
|
131
190
|
// Filter out folder marker objects (zero-byte files ending with /)
|
|
132
|
-
files =
|
|
191
|
+
files = (result.Contents || []).filter(f => !f.Key.endsWith('/'));
|
|
133
192
|
// S3 returns CommonPrefixes for folders when delimiter is used
|
|
134
|
-
folders = (result.CommonPrefixes || [])
|
|
193
|
+
folders = (result.CommonPrefixes || [])
|
|
194
|
+
.filter(p => p.Prefix !== effectivePrefix)
|
|
195
|
+
.map(p => p.Prefix);
|
|
135
196
|
nextContinuationToken = result.NextContinuationToken;
|
|
136
197
|
|
|
137
198
|
// Note: In optimized mode, totalFiles is unknown without scanning everything.
|
|
@@ -141,11 +202,12 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
141
202
|
|
|
142
203
|
// Fetch all files recursively (handling S3 pagination)
|
|
143
204
|
do {
|
|
144
|
-
const result = await listfiles(effectivePrefix, { continuationToken });
|
|
145
|
-
if (result.Contents) {
|
|
205
|
+
const result = await listfiles(effectivePrefix, { continuationToken, bucketName });
|
|
206
|
+
if (result.Contents?.length) {
|
|
146
207
|
// Filter out folder marker objects (zero-byte files ending with /)
|
|
147
|
-
const
|
|
148
|
-
|
|
208
|
+
for (const file of result.Contents) {
|
|
209
|
+
if (!file.Key.endsWith('/')) files.push(file);
|
|
210
|
+
}
|
|
149
211
|
}
|
|
150
212
|
continuationToken = result.nextToken;
|
|
151
213
|
} while (continuationToken);
|
|
@@ -153,23 +215,21 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
153
215
|
// If no files found under the app prefixed path, try root as a legacy fallback
|
|
154
216
|
if (!files.length && !search) {
|
|
155
217
|
try {
|
|
156
|
-
const rootResult = await listfiles('');
|
|
218
|
+
const rootResult = await listfiles('', { bucketName });
|
|
157
219
|
if ((rootResult.Contents || []).length) {
|
|
158
220
|
files = rootResult.Contents || [];
|
|
159
221
|
usedPrefix = '';
|
|
160
222
|
legacyFallback = true;
|
|
161
223
|
}
|
|
162
224
|
} catch (fallbackErr) {
|
|
163
|
-
|
|
225
|
+
debugApi('Fallback root listing failed: %s', fallbackErr.message);
|
|
164
226
|
}
|
|
165
227
|
}
|
|
166
228
|
|
|
167
229
|
// Apply search filter if provided
|
|
168
230
|
if (isSearch) {
|
|
169
|
-
const searchLower = search.toLowerCase();
|
|
170
|
-
files = files.filter(file =>
|
|
171
|
-
return file.Key.toLowerCase().includes(searchLower);
|
|
172
|
-
});
|
|
231
|
+
const searchLower = search.trim().toLowerCase();
|
|
232
|
+
files = files.filter(file => file.Key.toLowerCase().includes(searchLower));
|
|
173
233
|
}
|
|
174
234
|
}
|
|
175
235
|
|
|
@@ -218,9 +278,11 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
218
278
|
|
|
219
279
|
} catch (error) {
|
|
220
280
|
console.error("Error listing files:", error);
|
|
221
|
-
|
|
281
|
+
const mapped = classifyApiError(error, 'Error listing files');
|
|
282
|
+
return res.status(mapped.status).json({
|
|
222
283
|
success: false,
|
|
223
|
-
|
|
284
|
+
code: mapped.code,
|
|
285
|
+
error: mapped.message,
|
|
224
286
|
files: [],
|
|
225
287
|
totalFiles: 0
|
|
226
288
|
});
|
|
@@ -230,6 +292,7 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
230
292
|
// Standard single-file upload
|
|
231
293
|
router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file'), async (req, res) => {
|
|
232
294
|
try {
|
|
295
|
+
const bucketName = req.activeBucket;
|
|
233
296
|
if (!req.file) {
|
|
234
297
|
return res.status(400).json({ success: false, error: 'No file selected' });
|
|
235
298
|
}
|
|
@@ -250,32 +313,16 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
|
|
|
250
313
|
'upload-source': 'web-portal',
|
|
251
314
|
'user-agent': req.headers['user-agent'] || 'unknown'
|
|
252
315
|
},
|
|
316
|
+
bucketName,
|
|
253
317
|
preventOverwrite: true // Use atomic operation to prevent race conditions
|
|
254
318
|
};
|
|
255
319
|
|
|
256
320
|
try {
|
|
257
321
|
await uploadFile(key, req.file.buffer, req.file.mimetype, uploadOptions);
|
|
258
322
|
|
|
259
|
-
// Clean up folder marker if it exists in the parent directory
|
|
260
|
-
// This happens when a file is uploaded to a folder that was previously empty
|
|
323
|
+
// Clean up folder marker if it exists in the parent directory.
|
|
261
324
|
const folderPath = key.substring(0, key.lastIndexOf('/') + 1);
|
|
262
|
-
|
|
263
|
-
const folderMarkerKey = folderPath; // Already ends with /
|
|
264
|
-
try {
|
|
265
|
-
const markerExists = await fileExists(folderMarkerKey);
|
|
266
|
-
if (markerExists) {
|
|
267
|
-
// Check if this is actually a folder marker (zero-byte file)
|
|
268
|
-
const metadata = await getFileMetadata(folderMarkerKey);
|
|
269
|
-
if (metadata.ContentLength === 0 || metadata.Metadata?.marker === 'true') {
|
|
270
|
-
await deleteFile(folderMarkerKey);
|
|
271
|
-
console.log(`[mbkbucket] Cleaned up folder marker: ${folderMarkerKey}`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
} catch (cleanupErr) {
|
|
275
|
-
// Non-critical error, just log it
|
|
276
|
-
console.warn('[mbkbucket] Failed to clean up folder marker:', cleanupErr.message);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
325
|
+
await cleanupFolderMarker(folderPath, bucketName);
|
|
279
326
|
|
|
280
327
|
res.json({ success: true, message: 'File uploaded successfully', key: key });
|
|
281
328
|
} catch (uploadError) {
|
|
@@ -287,7 +334,7 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
|
|
|
287
334
|
}
|
|
288
335
|
} catch (error) {
|
|
289
336
|
console.error("Error uploading file:", error);
|
|
290
|
-
res
|
|
337
|
+
return sendApiError(res, error, 'Upload failed');
|
|
291
338
|
}
|
|
292
339
|
});
|
|
293
340
|
|
|
@@ -296,6 +343,7 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
|
|
|
296
343
|
// Returns: { uploadId, key }
|
|
297
344
|
router.post('/upload-init', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
298
345
|
try {
|
|
346
|
+
const bucketName = req.activeBucket;
|
|
299
347
|
const { fileName, prefix = '', contentType = 'application/octet-stream' } = req.body;
|
|
300
348
|
if (!fileName) return res.status(400).json({ success: false, error: 'fileName is required' });
|
|
301
349
|
|
|
@@ -306,14 +354,14 @@ router.post('/upload-init', validateSessionAndRole('SuperAdmin'), async (req, re
|
|
|
306
354
|
// Check if file exists before initiating multipart upload
|
|
307
355
|
// Note: This is still slightly racy, but multipart uploads take time so collision risk is lower
|
|
308
356
|
// A complete solution would require server-side locking or unique upload IDs
|
|
309
|
-
const exists = await fileExists(key);
|
|
357
|
+
const exists = await fileExists(key, bucketName);
|
|
310
358
|
if (exists) return res.status(409).json({ success: false, error: `File already exists: ${fileName}` });
|
|
311
359
|
|
|
312
|
-
const result = await createMultipartUpload(key, contentType);
|
|
360
|
+
const result = await createMultipartUpload(key, contentType, {}, bucketName);
|
|
313
361
|
return res.json({ success: true, uploadId: result.uploadId, key: result.key });
|
|
314
362
|
} catch (err) {
|
|
315
363
|
console.error('[mbkbucket] upload-init failed:', err);
|
|
316
|
-
return res
|
|
364
|
+
return sendApiError(res, err, 'upload-init failed');
|
|
317
365
|
}
|
|
318
366
|
});
|
|
319
367
|
|
|
@@ -322,6 +370,7 @@ router.post('/upload-init', validateSessionAndRole('SuperAdmin'), async (req, re
|
|
|
322
370
|
// Returns: { partNumber, ETag }
|
|
323
371
|
router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), uploadChunk, async (req, res) => {
|
|
324
372
|
try {
|
|
373
|
+
const bucketName = req.activeBucket;
|
|
325
374
|
const { uploadId, key, partNumber } = req.body;
|
|
326
375
|
if (!req.file || !uploadId || !key || !partNumber) {
|
|
327
376
|
return res.status(400).json({ success: false, error: 'chunk file, uploadId, key, and partNumber are required' });
|
|
@@ -331,11 +380,11 @@ router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), uploadChunk,
|
|
|
331
380
|
return res.status(400).json({ success: false, error: 'partNumber must be an integer between 1 and 10000' });
|
|
332
381
|
}
|
|
333
382
|
|
|
334
|
-
const result = await uploadPart(key, uploadId, part, req.file.buffer);
|
|
383
|
+
const result = await uploadPart(key, uploadId, part, req.file.buffer, bucketName);
|
|
335
384
|
return res.json({ success: true, partNumber: result.partNumber, ETag: result.ETag });
|
|
336
385
|
} catch (err) {
|
|
337
386
|
console.error('[mbkbucket] upload-chunk failed:', err);
|
|
338
|
-
return res
|
|
387
|
+
return sendApiError(res, err, 'upload-chunk failed');
|
|
339
388
|
}
|
|
340
389
|
});
|
|
341
390
|
|
|
@@ -344,35 +393,22 @@ router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), uploadChunk,
|
|
|
344
393
|
// Returns: { key }
|
|
345
394
|
router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
346
395
|
try {
|
|
396
|
+
const bucketName = req.activeBucket;
|
|
347
397
|
const { uploadId, key, parts } = req.body;
|
|
348
398
|
if (!uploadId || !key || !Array.isArray(parts) || !parts.length) {
|
|
349
399
|
return res.status(400).json({ success: false, error: 'uploadId, key, and parts array are required' });
|
|
350
400
|
}
|
|
351
401
|
|
|
352
|
-
const result = await completeMultipartUpload(key, uploadId, parts);
|
|
402
|
+
const result = await completeMultipartUpload(key, uploadId, parts, bucketName);
|
|
353
403
|
|
|
354
404
|
// Clean up folder marker if it exists in the parent directory
|
|
355
405
|
const folderPath = result.key.substring(0, result.key.lastIndexOf('/') + 1);
|
|
356
|
-
|
|
357
|
-
const folderMarkerKey = folderPath;
|
|
358
|
-
try {
|
|
359
|
-
const markerExists = await fileExists(folderMarkerKey);
|
|
360
|
-
if (markerExists) {
|
|
361
|
-
const metadata = await getFileMetadata(folderMarkerKey);
|
|
362
|
-
if (metadata.ContentLength === 0 || metadata.Metadata?.marker === 'true') {
|
|
363
|
-
await deleteFile(folderMarkerKey);
|
|
364
|
-
console.log(`[mbkbucket] Cleaned up folder marker: ${folderMarkerKey}`);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
} catch (cleanupErr) {
|
|
368
|
-
console.warn('[mbkbucket] Failed to clean up folder marker:', cleanupErr.message);
|
|
369
|
-
}
|
|
370
|
-
}
|
|
406
|
+
await cleanupFolderMarker(folderPath, bucketName);
|
|
371
407
|
|
|
372
408
|
return res.json({ success: true, message: 'File uploaded successfully', key: result.key });
|
|
373
409
|
} catch (err) {
|
|
374
410
|
console.error('[mbkbucket] upload-complete failed:', err);
|
|
375
|
-
return res
|
|
411
|
+
return sendApiError(res, err, 'upload-complete failed');
|
|
376
412
|
}
|
|
377
413
|
});
|
|
378
414
|
|
|
@@ -380,20 +416,22 @@ router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req
|
|
|
380
416
|
// Body: { uploadId, key }
|
|
381
417
|
router.post('/upload-abort', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
382
418
|
try {
|
|
419
|
+
const bucketName = req.activeBucket;
|
|
383
420
|
const { uploadId, key } = req.body;
|
|
384
421
|
if (!uploadId || !key) return res.status(400).json({ success: false, error: 'uploadId and key are required' });
|
|
385
422
|
|
|
386
|
-
await abortMultipartUpload(key, uploadId);
|
|
423
|
+
await abortMultipartUpload(key, uploadId, bucketName);
|
|
387
424
|
return res.json({ success: true, message: 'Multipart upload aborted' });
|
|
388
425
|
} catch (err) {
|
|
389
426
|
console.error('[mbkbucket] upload-abort failed:', err);
|
|
390
|
-
return res
|
|
427
|
+
return sendApiError(res, err, 'upload-abort failed');
|
|
391
428
|
}
|
|
392
429
|
});
|
|
393
430
|
|
|
394
431
|
// Create folder (zero-byte object with trailing slash)
|
|
395
432
|
router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
396
433
|
try {
|
|
434
|
+
const bucketName = req.activeBucket;
|
|
397
435
|
const { prefix = '', folderName } = req.body;
|
|
398
436
|
if (!folderName) return res.status(400).json({ success: false, error: 'folderName is required' });
|
|
399
437
|
|
|
@@ -403,11 +441,11 @@ router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req,
|
|
|
403
441
|
const folderKey = `${effectivePrefix.replace(/\/+$/, '')}/${folderName}/`;
|
|
404
442
|
|
|
405
443
|
// Check if folder marker already exists
|
|
406
|
-
const markerExists = await fileExists(folderKey);
|
|
444
|
+
const markerExists = await fileExists(folderKey, bucketName);
|
|
407
445
|
if (markerExists) return res.status(409).json({ success: false, error: 'Folder already exists' });
|
|
408
446
|
|
|
409
447
|
// Check if any files already exist under this prefix (folder already has content)
|
|
410
|
-
const existingFiles = await listfiles(folderKey, { maxKeys: 1 });
|
|
448
|
+
const existingFiles = await listfiles(folderKey, { maxKeys: 1, bucketName });
|
|
411
449
|
const hasContent = existingFiles.Contents && existingFiles.Contents.length > 0;
|
|
412
450
|
|
|
413
451
|
if (hasContent) {
|
|
@@ -422,23 +460,25 @@ router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req,
|
|
|
422
460
|
|
|
423
461
|
// Create zero-byte object to represent empty folder
|
|
424
462
|
await uploadFile(folderKey, Buffer.alloc(0), 'application/x-empty', {
|
|
425
|
-
metadata: { 'folder': 'true', 'marker': 'true' }
|
|
463
|
+
metadata: { 'folder': 'true', 'marker': 'true' },
|
|
464
|
+
bucketName
|
|
426
465
|
});
|
|
427
466
|
|
|
428
467
|
return res.json({ success: true, message: 'Folder created', key: folderKey });
|
|
429
468
|
} catch (err) {
|
|
430
469
|
console.error('Create folder failed:', err);
|
|
431
|
-
return res
|
|
470
|
+
return sendApiError(res, err, 'Create folder failed');
|
|
432
471
|
}
|
|
433
472
|
});
|
|
434
473
|
|
|
435
474
|
router.post('/delete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
436
475
|
try {
|
|
476
|
+
const bucketName = req.activeBucket;
|
|
437
477
|
const { key, keys, folder } = req.body;
|
|
438
478
|
|
|
439
479
|
if (keys && Array.isArray(keys) && keys.length) {
|
|
440
480
|
// Bulk delete
|
|
441
|
-
await deleteFiles(keys);
|
|
481
|
+
await deleteFiles(keys, bucketName);
|
|
442
482
|
return res.json({ success: true, message: 'Files deleted successfully' });
|
|
443
483
|
}
|
|
444
484
|
|
|
@@ -449,21 +489,22 @@ router.post('/delete', validateSessionAndRole('SuperAdmin'), async (req, res) =>
|
|
|
449
489
|
|
|
450
490
|
// If caller indicates it's a folder (or key ends with '/'), delete all objects under the prefix
|
|
451
491
|
if (folder === true || String(keyToDelete).endsWith('/')) {
|
|
452
|
-
await deleteFolder(keyToDelete);
|
|
492
|
+
await deleteFolder(keyToDelete, bucketName);
|
|
453
493
|
return res.json({ success: true, message: 'Folder deleted successfully' });
|
|
454
494
|
}
|
|
455
495
|
|
|
456
|
-
await deleteFile(keyToDelete);
|
|
496
|
+
await deleteFile(keyToDelete, bucketName);
|
|
457
497
|
res.json({ success: true, message: 'File deleted successfully' });
|
|
458
498
|
} catch (error) {
|
|
459
499
|
console.error("Error deleting file:", error);
|
|
460
|
-
res
|
|
500
|
+
return sendApiError(res, error, 'Delete failed');
|
|
461
501
|
}
|
|
462
502
|
});
|
|
463
503
|
|
|
464
504
|
router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
465
505
|
let stream = null;
|
|
466
506
|
try {
|
|
507
|
+
const bucketName = req.activeBucket;
|
|
467
508
|
const key = req.params.key;
|
|
468
509
|
let keyToUse;
|
|
469
510
|
try {
|
|
@@ -471,19 +512,37 @@ router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req
|
|
|
471
512
|
} catch (e) {
|
|
472
513
|
return res.status(400).json({ success: false, error: e.message });
|
|
473
514
|
}
|
|
474
|
-
const result = await downloadFile(keyToUse);
|
|
515
|
+
const result = await downloadFile(keyToUse, { bucketName });
|
|
475
516
|
stream = result.Body;
|
|
476
517
|
|
|
477
|
-
res.setHeader('Content-Disposition', `attachment; filename="${keyToUse.
|
|
478
|
-
|
|
518
|
+
res.setHeader('Content-Disposition', `attachment; filename="${getBaseName(keyToUse).replace(/"/g, '\\"')}"`);
|
|
519
|
+
const contentType = result.ContentType || 'application/octet-stream';
|
|
520
|
+
res.setHeader('Content-Type', contentType);
|
|
479
521
|
|
|
480
522
|
// Set Content-Length header for proper download progress indication
|
|
481
523
|
if (result.ContentLength) {
|
|
482
524
|
res.setHeader('Content-Length', result.ContentLength);
|
|
483
525
|
}
|
|
484
526
|
|
|
485
|
-
|
|
486
|
-
res.setHeader('
|
|
527
|
+
if (result.ETag) res.setHeader('ETag', result.ETag);
|
|
528
|
+
if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
|
|
529
|
+
|
|
530
|
+
const typeLower = String(contentType).toLowerCase();
|
|
531
|
+
const isSensitiveText = typeLower.startsWith('text/')
|
|
532
|
+
|| typeLower.includes('json')
|
|
533
|
+
|| typeLower.includes('xml')
|
|
534
|
+
|| typeLower.includes('javascript')
|
|
535
|
+
|| typeLower.includes('typescript');
|
|
536
|
+
const isLargeFile = Number(result.ContentLength || 0) > 50 * 1024 * 1024;
|
|
537
|
+
|
|
538
|
+
// Smarter cache policy for authenticated downloads.
|
|
539
|
+
if (isSensitiveText) {
|
|
540
|
+
res.setHeader('Cache-Control', 'private, no-store');
|
|
541
|
+
} else if (isLargeFile) {
|
|
542
|
+
res.setHeader('Cache-Control', 'private, max-age=600, must-revalidate');
|
|
543
|
+
} else {
|
|
544
|
+
res.setHeader('Cache-Control', 'private, max-age=1800, must-revalidate');
|
|
545
|
+
}
|
|
487
546
|
|
|
488
547
|
// Proper stream error handling
|
|
489
548
|
stream.on('error', (err) => {
|
|
@@ -523,4 +582,4 @@ router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req
|
|
|
523
582
|
}
|
|
524
583
|
});
|
|
525
584
|
|
|
526
|
-
export default router;
|
|
585
|
+
export default router;
|
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);
|