mbkbucket 2.0.0 → 2.1.1
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 +4 -2
- package/LICENSE +161 -335
- package/README.md +5 -4
- package/index.js +9 -9
- package/lib/config/index.js +27 -11
- package/lib/config/validation.js +5 -2
- package/lib/debug.js +12 -0
- package/lib/routes/api.js +53 -63
- package/lib/routes/index.js +2 -2
- package/lib/routes/view.js +40 -20
- package/lib/s3.js +44 -29
- package/package.json +12 -6
- package/public/bucketadmin.css +124 -1
- package/public/bucketadmin.js +182 -88
- package/views/bucket.handlebars +0 -1
- package/views/bucketadmincontent.handlebars +15 -15
package/lib/config/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createRequire } from "module";
|
|
2
2
|
import dotenv from "dotenv";
|
|
3
3
|
import { parseAndValidateMbkbucketVar, parseAndValidateBucketConnection } from "./validation.js";
|
|
4
|
+
import { createLogger } from "../debug.js";
|
|
4
5
|
|
|
5
6
|
dotenv.config();
|
|
7
|
+
const debugConfig = createLogger('config');
|
|
6
8
|
|
|
7
9
|
// Load package.json from mbkbucket package (not parent project)
|
|
8
10
|
const require = createRequire(import.meta.url);
|
|
@@ -28,19 +30,31 @@ try {
|
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
function compareVersions(a, b) {
|
|
34
|
+
const left = String(a || '').split(/[.-]/).map((part) => parseInt(part, 10) || 0);
|
|
35
|
+
const right = String(b || '').split(/[.-]/).map((part) => parseInt(part, 10) || 0);
|
|
36
|
+
const length = Math.max(left.length, right.length);
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < length; i += 1) {
|
|
39
|
+
const diff = (left[i] || 0) - (right[i] || 0);
|
|
40
|
+
if (diff !== 0) return diff;
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
// Fetch latest version from GitHub
|
|
32
46
|
async function getLatestVersion() {
|
|
33
47
|
try {
|
|
34
48
|
const response = await fetch('https://raw.githubusercontent.com/MIbnEKhalid/mbkbucket/master/package.json');
|
|
35
49
|
if (!response.ok) {
|
|
36
|
-
|
|
50
|
+
debugConfig('GitHub API responded with status %s', response.status);
|
|
37
51
|
return "0.0.0";
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
const latestPackageJson = await response.json();
|
|
41
55
|
return latestPackageJson.version;
|
|
42
56
|
} catch (error) {
|
|
43
|
-
|
|
57
|
+
debugConfig('Error fetching latest version from GitHub: %s', error.message);
|
|
44
58
|
return null;
|
|
45
59
|
}
|
|
46
60
|
}
|
|
@@ -49,13 +63,15 @@ async function getLatestVersion() {
|
|
|
49
63
|
async function checkVersion() {
|
|
50
64
|
try {
|
|
51
65
|
const latestVersion = await getLatestVersion();
|
|
52
|
-
if (latestVersion && latestVersion
|
|
53
|
-
|
|
66
|
+
if (latestVersion && compareVersions(latestVersion, packageJson.version) > 0) {
|
|
67
|
+
debugConfig('Current version (%s) is outdated. Latest version: %s.', packageJson.version, latestVersion);
|
|
68
|
+
} else if (latestVersion && compareVersions(latestVersion, packageJson.version) < 0) {
|
|
69
|
+
debugConfig('Current version (%s) is newer than published version: %s.', packageJson.version, latestVersion);
|
|
54
70
|
} else if (latestVersion) {
|
|
55
|
-
|
|
71
|
+
debugConfig('Running latest version (%s).', packageJson.version);
|
|
56
72
|
}
|
|
57
73
|
} catch (error) {
|
|
58
|
-
|
|
74
|
+
debugConfig('Failed to check for updates: %s', error.message);
|
|
59
75
|
}
|
|
60
76
|
}
|
|
61
77
|
|
|
@@ -64,9 +80,9 @@ function validateConfiguration() {
|
|
|
64
80
|
try {
|
|
65
81
|
const parsed = parseAndValidateMbkbucketVar(process.env.mbkbucketVar);
|
|
66
82
|
if (!process.env.mbkbucketVar) {
|
|
67
|
-
|
|
83
|
+
debugConfig('mbkbucketVar not defined, using defaults');
|
|
68
84
|
}
|
|
69
|
-
|
|
85
|
+
debugConfig('Configuration loaded');
|
|
70
86
|
return parsed;
|
|
71
87
|
} catch (error) {
|
|
72
88
|
throw new Error(`[mbkbucket] Configuration Validation Failed:\n - ${error.message}`);
|
|
@@ -86,7 +102,7 @@ function validateBucketConnection() {
|
|
|
86
102
|
const bucketConfig = parseAndValidateBucketConnection(process.env.BucketConnection);
|
|
87
103
|
const bucketNames = Object.keys(bucketConfig || {});
|
|
88
104
|
|
|
89
|
-
|
|
105
|
+
debugConfig('BucketConnection validated (%s bucket(s): %s)', bucketNames.length, bucketNames.join(', '));
|
|
90
106
|
return bucketConfig;
|
|
91
107
|
} catch (error) {
|
|
92
108
|
console.error(`[mbkbucket] ❌ BucketConnection validation failed: ${error.message}`);
|
|
@@ -100,11 +116,11 @@ function validateBucketConnection() {
|
|
|
100
116
|
|
|
101
117
|
// Parse and validate mbkbucketVar once
|
|
102
118
|
const mbkbucketVar = validateConfiguration();
|
|
103
|
-
|
|
119
|
+
debugConfig('mbkbucketVar configuration: %O', mbkbucketVar);
|
|
104
120
|
|
|
105
121
|
// Validate BucketConnection
|
|
106
122
|
validateBucketConnection();
|
|
107
123
|
|
|
108
124
|
export { packageJson, appVersion, getLatestVersion, checkVersion };
|
|
109
125
|
export { mbkbucketVar };
|
|
110
|
-
export { parseAndValidateMbkbucketVar, parseAndValidateBucketConnection };
|
|
126
|
+
export { parseAndValidateMbkbucketVar, parseAndValidateBucketConnection };
|
package/lib/config/validation.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
const TRUE_VALUES = new Set(['true', 't', '1', 'yes', 'y', 'on']);
|
|
2
|
+
const FALSE_VALUES = new Set(['false', 'f', '0', 'no', 'n', 'off']);
|
|
3
|
+
|
|
1
4
|
function normalizeBooleanLike(value) {
|
|
2
5
|
if (typeof value === 'boolean') return value;
|
|
3
6
|
if (typeof value === 'string') {
|
|
4
7
|
const normalized = value.trim().toLowerCase();
|
|
5
|
-
if (
|
|
6
|
-
if (
|
|
8
|
+
if (TRUE_VALUES.has(normalized)) return true;
|
|
9
|
+
if (FALSE_VALUES.has(normalized)) return false;
|
|
7
10
|
}
|
|
8
11
|
return value;
|
|
9
12
|
}
|
package/lib/debug.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import createDebug from "debug";
|
|
2
|
+
import dotenv from "dotenv";
|
|
3
|
+
|
|
4
|
+
dotenv.config();
|
|
5
|
+
|
|
6
|
+
if (process.env.DEBUG) {
|
|
7
|
+
createDebug.enable(process.env.DEBUG);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createLogger(namespace) {
|
|
11
|
+
return createDebug(`mbkbucket:${namespace}`);
|
|
12
|
+
}
|
package/lib/routes/api.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import {
|
|
2
|
+
import { sessRole, } 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
10
|
function classifyApiError(error, fallbackMessage = 'Request failed') {
|
|
9
11
|
const message = String(error?.message || fallbackMessage);
|
|
@@ -35,6 +37,12 @@ function sendApiError(res, error, fallbackMessage = 'Request failed') {
|
|
|
35
37
|
return res.status(mapped.status).json({ success: false, code: mapped.code, error: mapped.message });
|
|
36
38
|
}
|
|
37
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
|
+
|
|
38
46
|
// Central bucket guard for all API routes under /mbkbucket
|
|
39
47
|
router.use((req, res, next) => {
|
|
40
48
|
if (req.bucketResolveError) {
|
|
@@ -46,9 +54,8 @@ router.use((req, res, next) => {
|
|
|
46
54
|
next();
|
|
47
55
|
});
|
|
48
56
|
|
|
49
|
-
// This is the maximum size we allow per
|
|
50
|
-
//
|
|
51
|
-
// If you need to support larger chunks, increase this accordingly.
|
|
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.
|
|
52
59
|
const MAX_CHUNK_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
53
60
|
|
|
54
61
|
const upload = multer({
|
|
@@ -72,8 +79,22 @@ const uploadChunk = (req, res, next) => {
|
|
|
72
79
|
});
|
|
73
80
|
};
|
|
74
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
|
+
|
|
75
96
|
// API endpoint for multipart upload cleanup
|
|
76
|
-
router.post('/api/cleanup-uploads',
|
|
97
|
+
router.post('/api/cleanup-uploads', sessRole('SuperAdmin'), async (req, res) => {
|
|
77
98
|
try {
|
|
78
99
|
const { olderThanDays = 7, prefix = '' } = req.body;
|
|
79
100
|
const bucketName = req.activeBucket;
|
|
@@ -84,7 +105,7 @@ router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async
|
|
|
84
105
|
return res.status(400).json({ success: false, error: 'olderThanDays must be a positive integer' });
|
|
85
106
|
}
|
|
86
107
|
|
|
87
|
-
|
|
108
|
+
debugApi('Starting cleanup of incomplete uploads older than %s days', days);
|
|
88
109
|
const result = await cleanupIncompleteMultipartUploads(days, prefix, bucketName);
|
|
89
110
|
|
|
90
111
|
res.json({
|
|
@@ -99,7 +120,7 @@ router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async
|
|
|
99
120
|
});
|
|
100
121
|
|
|
101
122
|
// API endpoint to list incomplete multipart uploads
|
|
102
|
-
router.get('/api/incomplete-uploads',
|
|
123
|
+
router.get('/api/incomplete-uploads', sessRole('SuperAdmin'), async (req, res) => {
|
|
103
124
|
try {
|
|
104
125
|
const { prefix = '' } = req.query;
|
|
105
126
|
const bucketName = req.activeBucket;
|
|
@@ -122,7 +143,7 @@ router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), asyn
|
|
|
122
143
|
});
|
|
123
144
|
|
|
124
145
|
// API endpoint for fetching files
|
|
125
|
-
router.get('/api/files',
|
|
146
|
+
router.get('/api/files', sessRole('SuperAdmin'), async (req, res) => {
|
|
126
147
|
const { prefix = '', page = '1', search = '', recursive = 'true', token = '' } = req.query;
|
|
127
148
|
const pageSize = 100;
|
|
128
149
|
const bucketName = req.activeBucket;
|
|
@@ -166,11 +187,12 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
166
187
|
bucketName
|
|
167
188
|
});
|
|
168
189
|
|
|
169
|
-
files = result.Contents || [];
|
|
170
190
|
// Filter out folder marker objects (zero-byte files ending with /)
|
|
171
|
-
files =
|
|
191
|
+
files = (result.Contents || []).filter(f => !f.Key.endsWith('/'));
|
|
172
192
|
// S3 returns CommonPrefixes for folders when delimiter is used
|
|
173
|
-
folders = (result.CommonPrefixes || [])
|
|
193
|
+
folders = (result.CommonPrefixes || [])
|
|
194
|
+
.filter(p => p.Prefix !== effectivePrefix)
|
|
195
|
+
.map(p => p.Prefix);
|
|
174
196
|
nextContinuationToken = result.NextContinuationToken;
|
|
175
197
|
|
|
176
198
|
// Note: In optimized mode, totalFiles is unknown without scanning everything.
|
|
@@ -181,10 +203,11 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
181
203
|
// Fetch all files recursively (handling S3 pagination)
|
|
182
204
|
do {
|
|
183
205
|
const result = await listfiles(effectivePrefix, { continuationToken, bucketName });
|
|
184
|
-
if (result.Contents) {
|
|
206
|
+
if (result.Contents?.length) {
|
|
185
207
|
// Filter out folder marker objects (zero-byte files ending with /)
|
|
186
|
-
const
|
|
187
|
-
|
|
208
|
+
for (const file of result.Contents) {
|
|
209
|
+
if (!file.Key.endsWith('/')) files.push(file);
|
|
210
|
+
}
|
|
188
211
|
}
|
|
189
212
|
continuationToken = result.nextToken;
|
|
190
213
|
} while (continuationToken);
|
|
@@ -199,16 +222,14 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
199
222
|
legacyFallback = true;
|
|
200
223
|
}
|
|
201
224
|
} catch (fallbackErr) {
|
|
202
|
-
|
|
225
|
+
debugApi('Fallback root listing failed: %s', fallbackErr.message);
|
|
203
226
|
}
|
|
204
227
|
}
|
|
205
228
|
|
|
206
229
|
// Apply search filter if provided
|
|
207
230
|
if (isSearch) {
|
|
208
|
-
const searchLower = search.toLowerCase();
|
|
209
|
-
files = files.filter(file =>
|
|
210
|
-
return file.Key.toLowerCase().includes(searchLower);
|
|
211
|
-
});
|
|
231
|
+
const searchLower = search.trim().toLowerCase();
|
|
232
|
+
files = files.filter(file => file.Key.toLowerCase().includes(searchLower));
|
|
212
233
|
}
|
|
213
234
|
}
|
|
214
235
|
|
|
@@ -269,7 +290,7 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
269
290
|
});
|
|
270
291
|
|
|
271
292
|
// Standard single-file upload
|
|
272
|
-
router.post('/upload',
|
|
293
|
+
router.post('/upload', sessRole('SuperAdmin'), upload.single('file'), async (req, res) => {
|
|
273
294
|
try {
|
|
274
295
|
const bucketName = req.activeBucket;
|
|
275
296
|
if (!req.file) {
|
|
@@ -299,26 +320,9 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
|
|
|
299
320
|
try {
|
|
300
321
|
await uploadFile(key, req.file.buffer, req.file.mimetype, uploadOptions);
|
|
301
322
|
|
|
302
|
-
// Clean up folder marker if it exists in the parent directory
|
|
303
|
-
// 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.
|
|
304
324
|
const folderPath = key.substring(0, key.lastIndexOf('/') + 1);
|
|
305
|
-
|
|
306
|
-
const folderMarkerKey = folderPath; // Already ends with /
|
|
307
|
-
try {
|
|
308
|
-
const markerExists = await fileExists(folderMarkerKey, bucketName);
|
|
309
|
-
if (markerExists) {
|
|
310
|
-
// Check if this is actually a folder marker (zero-byte file)
|
|
311
|
-
const metadata = await getFileMetadata(folderMarkerKey, bucketName);
|
|
312
|
-
if (metadata.ContentLength === 0 || metadata.Metadata?.marker === 'true') {
|
|
313
|
-
await deleteFile(folderMarkerKey, bucketName);
|
|
314
|
-
console.log(`[mbkbucket] Cleaned up folder marker: ${folderMarkerKey}`);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
} catch (cleanupErr) {
|
|
318
|
-
// Non-critical error, just log it
|
|
319
|
-
console.warn('[mbkbucket] Failed to clean up folder marker:', cleanupErr.message);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
325
|
+
await cleanupFolderMarker(folderPath, bucketName);
|
|
322
326
|
|
|
323
327
|
res.json({ success: true, message: 'File uploaded successfully', key: key });
|
|
324
328
|
} catch (uploadError) {
|
|
@@ -337,7 +341,7 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
|
|
|
337
341
|
// S3 Multipart Upload — Step 1: Initiate
|
|
338
342
|
// Body: { fileName, prefix?, contentType? }
|
|
339
343
|
// Returns: { uploadId, key }
|
|
340
|
-
router.post('/upload-init',
|
|
344
|
+
router.post('/upload-init', sessRole('SuperAdmin'), async (req, res) => {
|
|
341
345
|
try {
|
|
342
346
|
const bucketName = req.activeBucket;
|
|
343
347
|
const { fileName, prefix = '', contentType = 'application/octet-stream' } = req.body;
|
|
@@ -364,7 +368,7 @@ router.post('/upload-init', validateSessionAndRole('SuperAdmin'), async (req, re
|
|
|
364
368
|
// S3 Multipart Upload — Step 2: Upload a part
|
|
365
369
|
// FormData: chunk (file), partNumber (1-based integer), uploadId, key
|
|
366
370
|
// Returns: { partNumber, ETag }
|
|
367
|
-
router.post('/upload-chunk',
|
|
371
|
+
router.post('/upload-chunk', sessRole('SuperAdmin'), uploadChunk, async (req, res) => {
|
|
368
372
|
try {
|
|
369
373
|
const bucketName = req.activeBucket;
|
|
370
374
|
const { uploadId, key, partNumber } = req.body;
|
|
@@ -387,7 +391,7 @@ router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), uploadChunk,
|
|
|
387
391
|
// S3 Multipart Upload — Step 3: Complete
|
|
388
392
|
// Body: { uploadId, key, parts: [{ partNumber, ETag }] }
|
|
389
393
|
// Returns: { key }
|
|
390
|
-
router.post('/upload-complete',
|
|
394
|
+
router.post('/upload-complete', sessRole('SuperAdmin'), async (req, res) => {
|
|
391
395
|
try {
|
|
392
396
|
const bucketName = req.activeBucket;
|
|
393
397
|
const { uploadId, key, parts } = req.body;
|
|
@@ -399,21 +403,7 @@ router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req
|
|
|
399
403
|
|
|
400
404
|
// Clean up folder marker if it exists in the parent directory
|
|
401
405
|
const folderPath = result.key.substring(0, result.key.lastIndexOf('/') + 1);
|
|
402
|
-
|
|
403
|
-
const folderMarkerKey = folderPath;
|
|
404
|
-
try {
|
|
405
|
-
const markerExists = await fileExists(folderMarkerKey, bucketName);
|
|
406
|
-
if (markerExists) {
|
|
407
|
-
const metadata = await getFileMetadata(folderMarkerKey, bucketName);
|
|
408
|
-
if (metadata.ContentLength === 0 || metadata.Metadata?.marker === 'true') {
|
|
409
|
-
await deleteFile(folderMarkerKey, bucketName);
|
|
410
|
-
console.log(`[mbkbucket] Cleaned up folder marker: ${folderMarkerKey}`);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
} catch (cleanupErr) {
|
|
414
|
-
console.warn('[mbkbucket] Failed to clean up folder marker:', cleanupErr.message);
|
|
415
|
-
}
|
|
416
|
-
}
|
|
406
|
+
await cleanupFolderMarker(folderPath, bucketName);
|
|
417
407
|
|
|
418
408
|
return res.json({ success: true, message: 'File uploaded successfully', key: result.key });
|
|
419
409
|
} catch (err) {
|
|
@@ -424,7 +414,7 @@ router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req
|
|
|
424
414
|
|
|
425
415
|
// S3 Multipart Upload — Abort (cleanup on client cancel/error)
|
|
426
416
|
// Body: { uploadId, key }
|
|
427
|
-
router.post('/upload-abort',
|
|
417
|
+
router.post('/upload-abort', sessRole('SuperAdmin'), async (req, res) => {
|
|
428
418
|
try {
|
|
429
419
|
const bucketName = req.activeBucket;
|
|
430
420
|
const { uploadId, key } = req.body;
|
|
@@ -439,7 +429,7 @@ router.post('/upload-abort', validateSessionAndRole('SuperAdmin'), async (req, r
|
|
|
439
429
|
});
|
|
440
430
|
|
|
441
431
|
// Create folder (zero-byte object with trailing slash)
|
|
442
|
-
router.post('/create-folder',
|
|
432
|
+
router.post('/create-folder', sessRole('SuperAdmin'), async (req, res) => {
|
|
443
433
|
try {
|
|
444
434
|
const bucketName = req.activeBucket;
|
|
445
435
|
const { prefix = '', folderName } = req.body;
|
|
@@ -481,7 +471,7 @@ router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req,
|
|
|
481
471
|
}
|
|
482
472
|
});
|
|
483
473
|
|
|
484
|
-
router.post('/delete',
|
|
474
|
+
router.post('/delete', sessRole('SuperAdmin'), async (req, res) => {
|
|
485
475
|
try {
|
|
486
476
|
const bucketName = req.activeBucket;
|
|
487
477
|
const { key, keys, folder } = req.body;
|
|
@@ -511,7 +501,7 @@ router.post('/delete', validateSessionAndRole('SuperAdmin'), async (req, res) =>
|
|
|
511
501
|
}
|
|
512
502
|
});
|
|
513
503
|
|
|
514
|
-
router.get('/download/:key(*)',
|
|
504
|
+
router.get('/download/:key(*)', sessRole('SuperAdmin'), async (req, res) => {
|
|
515
505
|
let stream = null;
|
|
516
506
|
try {
|
|
517
507
|
const bucketName = req.activeBucket;
|
|
@@ -525,7 +515,7 @@ router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req
|
|
|
525
515
|
const result = await downloadFile(keyToUse, { bucketName });
|
|
526
516
|
stream = result.Body;
|
|
527
517
|
|
|
528
|
-
res.setHeader('Content-Disposition', `attachment; filename="${keyToUse.
|
|
518
|
+
res.setHeader('Content-Disposition', `attachment; filename="${getBaseName(keyToUse).replace(/"/g, '\\"')}"`);
|
|
529
519
|
const contentType = result.ContentType || 'application/octet-stream';
|
|
530
520
|
res.setHeader('Content-Type', contentType);
|
|
531
521
|
|
|
@@ -592,4 +582,4 @@ router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req
|
|
|
592
582
|
}
|
|
593
583
|
});
|
|
594
584
|
|
|
595
|
-
export default router;
|
|
585
|
+
export default router;
|
package/lib/routes/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import express from "express";
|
|
|
2
2
|
import infoRoutes from "./info.js";
|
|
3
3
|
import viewRoutes from "./view.js";
|
|
4
4
|
import apiRoutes from "./api.js";
|
|
5
|
-
import {
|
|
5
|
+
import { sessRole, renderPage, mbkautheVar } from "mbkauthe";
|
|
6
6
|
import { resolveBucketName, getAvailableBucketNames } from "../s3.js";
|
|
7
7
|
|
|
8
8
|
const router = express.Router();
|
|
@@ -62,7 +62,7 @@ function renderBucketDashboard(req, res) {
|
|
|
62
62
|
});
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
router.get('/mbkbucket',
|
|
65
|
+
router.get('/mbkbucket', sessRole('SuperAdmin'), async (req, res) => {
|
|
66
66
|
return renderBucketDashboard(req, res);
|
|
67
67
|
});
|
|
68
68
|
|
package/lib/routes/view.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import {
|
|
2
|
+
import { sessRole } from "mbkauthe";
|
|
3
3
|
import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, generateSignedUrl, resolveBucketName } from "../s3.js";
|
|
4
4
|
import { mbkbucketVar } from "../config/index.js";
|
|
5
5
|
import rateLimit from "express-rate-limit";
|
|
6
|
+
import { createLogger } from "../debug.js";
|
|
7
|
+
|
|
8
|
+
const debugView = createLogger('view');
|
|
6
9
|
|
|
7
10
|
// ---------------------------------------------------------------------------
|
|
8
11
|
// p_view security: rate limiter + basic bot/security middleware
|
|
@@ -42,9 +45,6 @@ function pviewSecurity(req, res, next) {
|
|
|
42
45
|
return res.status(403).json({ message: 'Forbidden' });
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
// Rate limit check - ensure unique fingerprint per request
|
|
46
|
-
const fingerprint = `${req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress}:${ua}`;
|
|
47
|
-
|
|
48
48
|
// Verify Accept header is present (legitimate browsers always send this)
|
|
49
49
|
if (!req.headers['accept']) {
|
|
50
50
|
return res.status(403).json({ message: 'Forbidden' });
|
|
@@ -63,6 +63,21 @@ function pviewSecurity(req, res, next) {
|
|
|
63
63
|
|
|
64
64
|
const router = express.Router();
|
|
65
65
|
|
|
66
|
+
function escapeHtml(value = '') {
|
|
67
|
+
return String(value)
|
|
68
|
+
.replace(/&/g, '&')
|
|
69
|
+
.replace(/</g, '<')
|
|
70
|
+
.replace(/>/g, '>')
|
|
71
|
+
.replace(/"/g, '"')
|
|
72
|
+
.replace(/'/g, ''');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getBaseName(path = '') {
|
|
76
|
+
const value = String(path || '');
|
|
77
|
+
const lastSlashIndex = value.lastIndexOf('/');
|
|
78
|
+
return lastSlashIndex === -1 ? value : value.substring(lastSlashIndex + 1);
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
function classifyViewError(error, fallbackMessage = 'Request failed') {
|
|
67
82
|
const message = String(error?.message || fallbackMessage);
|
|
68
83
|
const lower = message.toLowerCase();
|
|
@@ -98,7 +113,7 @@ router.use((req, res, next) => {
|
|
|
98
113
|
});
|
|
99
114
|
|
|
100
115
|
// New: lightweight player page for robust playback in browsers
|
|
101
|
-
router.get('/player/:key(*)',
|
|
116
|
+
router.get('/player/:key(*)', sessRole('SuperAdmin'), async (req, res) => {
|
|
102
117
|
try {
|
|
103
118
|
const bucketName = req.activeBucket;
|
|
104
119
|
const key = req.params.key;
|
|
@@ -109,7 +124,8 @@ router.get('/player/:key(*)', validateSessionAndRole('SuperAdmin'), async (req,
|
|
|
109
124
|
return res.status(400).send(e.message);
|
|
110
125
|
}
|
|
111
126
|
const encoded = encodeURIComponent(keyToUse);
|
|
112
|
-
const fileName = keyToUse
|
|
127
|
+
const fileName = getBaseName(keyToUse);
|
|
128
|
+
const safeFileName = escapeHtml(fileName);
|
|
113
129
|
|
|
114
130
|
const bucketQuery = `?bucket=${encodeURIComponent(bucketName)}`;
|
|
115
131
|
const html = `<!doctype html>
|
|
@@ -117,7 +133,7 @@ router.get('/player/:key(*)', validateSessionAndRole('SuperAdmin'), async (req,
|
|
|
117
133
|
<head>
|
|
118
134
|
<meta charset="utf-8">
|
|
119
135
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
120
|
-
<title>Preview: ${
|
|
136
|
+
<title>Preview: ${safeFileName}</title>
|
|
121
137
|
<style>
|
|
122
138
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
123
139
|
body { margin: 0; display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #000; color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
|
@@ -127,7 +143,7 @@ router.get('/player/:key(*)', validateSessionAndRole('SuperAdmin'), async (req,
|
|
|
127
143
|
</style>
|
|
128
144
|
</head>
|
|
129
145
|
<body>
|
|
130
|
-
<div class="file-name">${
|
|
146
|
+
<div class="file-name">${safeFileName}</div>
|
|
131
147
|
<div class="player-container">
|
|
132
148
|
<video controls preload="metadata" src="/mbkbucket/view/${encoded}${bucketQuery}">
|
|
133
149
|
Your browser does not support the video tag.
|
|
@@ -159,6 +175,9 @@ const VIEWABLE_TYPES = new Set([
|
|
|
159
175
|
]);
|
|
160
176
|
// Pre-sorted for 415 error responses (avoids re-sorting on every error)
|
|
161
177
|
const VIEWABLE_TYPES_SORTED = Array.from(VIEWABLE_TYPES).sort();
|
|
178
|
+
const STATIC_ASSET_TYPES = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'css', 'js']);
|
|
179
|
+
const VIDEO_TYPES = new Set(['mp4', 'webm', 'ogg', 'avi', 'mov']);
|
|
180
|
+
const AUDIO_TYPES = new Set(['mp3', 'wav', 'flac', 'aac', 'm4a']);
|
|
162
181
|
|
|
163
182
|
const MIME_TYPES = {
|
|
164
183
|
// Images
|
|
@@ -218,10 +237,11 @@ function buildCacheControl(contentType, { isStaticAsset = false, supportsRanges
|
|
|
218
237
|
}
|
|
219
238
|
|
|
220
239
|
async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName, publicCache = false } = {}) {
|
|
221
|
-
const fileName = keyToUse
|
|
222
|
-
const
|
|
240
|
+
const fileName = getBaseName(keyToUse);
|
|
241
|
+
const dotIndex = fileName.lastIndexOf('.');
|
|
242
|
+
const fileExtension = dotIndex === -1 ? '' : fileName.substring(dotIndex + 1).toLowerCase();
|
|
223
243
|
|
|
224
|
-
|
|
244
|
+
debugView('Request for %s (type: %s, range: %s)', fileName, fileExtension, req.headers.range || 'none');
|
|
225
245
|
|
|
226
246
|
if (!VIEWABLE_TYPES.has(fileExtension)) {
|
|
227
247
|
return res.status(415).json({
|
|
@@ -234,9 +254,9 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
|
|
|
234
254
|
const ifModifiedSince = req.headers['if-modified-since'];
|
|
235
255
|
const rangeHeader = req.headers.range;
|
|
236
256
|
|
|
237
|
-
const isStaticAsset =
|
|
238
|
-
const isVideo =
|
|
239
|
-
const isAudio =
|
|
257
|
+
const isStaticAsset = STATIC_ASSET_TYPES.has(fileExtension);
|
|
258
|
+
const isVideo = VIDEO_TYPES.has(fileExtension);
|
|
259
|
+
const isAudio = AUDIO_TYPES.has(fileExtension);
|
|
240
260
|
const isPdf = fileExtension === 'pdf';
|
|
241
261
|
const supportsRanges = isVideo || isAudio || isPdf;
|
|
242
262
|
const contentType = MIME_TYPES[fileExtension] || 'application/octet-stream';
|
|
@@ -247,7 +267,7 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
|
|
|
247
267
|
// requested byte range — avoids downloading the full file first.
|
|
248
268
|
// ---------------------------------------------------------------------------
|
|
249
269
|
if (supportsRanges && rangeHeader) {
|
|
250
|
-
|
|
270
|
+
debugView('Processing range request: %s for %s', rangeHeader, fileName);
|
|
251
271
|
let meta;
|
|
252
272
|
try {
|
|
253
273
|
meta = await getFileMetadata(keyToUse, bucketName);
|
|
@@ -309,10 +329,10 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
|
|
|
309
329
|
const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, bucketName });
|
|
310
330
|
|
|
311
331
|
if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
|
|
312
|
-
|
|
332
|
+
debugView('Range size mismatch for %s: expected=%s got=%s', keyToUse, chunksize, rangeResult.ContentLength);
|
|
313
333
|
}
|
|
314
334
|
|
|
315
|
-
|
|
335
|
+
debugView('Serving range bytes %s-%s/%s (%s bytes) for %s', start, end, total, chunksize, fileName);
|
|
316
336
|
|
|
317
337
|
res.status(206);
|
|
318
338
|
res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
|
|
@@ -370,7 +390,7 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
|
|
|
370
390
|
};
|
|
371
391
|
|
|
372
392
|
if (isPdf) {
|
|
373
|
-
|
|
393
|
+
debugView('Serving full PDF (no range header) for %s', fileName);
|
|
374
394
|
}
|
|
375
395
|
|
|
376
396
|
let result;
|
|
@@ -442,7 +462,7 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
|
|
|
442
462
|
// Routes
|
|
443
463
|
// ---------------------------------------------------------------------------
|
|
444
464
|
|
|
445
|
-
router.get('/view/:key(*)',
|
|
465
|
+
router.get('/view/:key(*)', sessRole('SuperAdmin'), async (req, res) => {
|
|
446
466
|
try {
|
|
447
467
|
const bucketName = req.activeBucket;
|
|
448
468
|
let keyToUse;
|
|
@@ -508,4 +528,4 @@ if (mbkbucketVar?.publiView_enabled) {
|
|
|
508
528
|
});
|
|
509
529
|
}
|
|
510
530
|
|
|
511
|
-
export default router;
|
|
531
|
+
export default router;
|