mbkbucket 2.0.0 → 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/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 +41 -51
- package/lib/routes/view.js +37 -17
- package/lib/s3.js +41 -28
- package/package.json +5 -4
- package/public/bucketadmin.css +124 -1
- package/public/bucketadmin.js +177 -86
- package/views/bucketadmincontent.handlebars +15 -15
package/index.js
CHANGED
|
@@ -9,11 +9,14 @@ import rateLimit from 'express-rate-limit';
|
|
|
9
9
|
import bucketRoutes from "./lib/routes/index.js";
|
|
10
10
|
import { checkVersion } from "./lib/config/index.js";
|
|
11
11
|
import { packageJson } from "./lib/config/index.js";
|
|
12
|
+
import { createLogger } from "./lib/debug.js";
|
|
12
13
|
|
|
13
14
|
dotenv.config();
|
|
15
|
+
const debugServer = createLogger('server');
|
|
14
16
|
|
|
15
17
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
18
|
const __dirname = path.dirname(__filename);
|
|
19
|
+
const isDevMode = process.env.NODE_ENV === "dev";
|
|
17
20
|
|
|
18
21
|
const server = express();
|
|
19
22
|
server.set('trust proxy', 1);
|
|
@@ -89,11 +92,8 @@ server.set("views", [
|
|
|
89
92
|
server.use(mbkautheRoutes);
|
|
90
93
|
server.use(generalLimiter);
|
|
91
94
|
server.use(bucketRoutes);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
console.log("[mbkbucket] Dev mode is enabled. Starting server in dev mode.");
|
|
96
|
-
|
|
95
|
+
if (isDevMode) {
|
|
96
|
+
debugServer("Dev mode is enabled. Starting server in dev mode.");
|
|
97
97
|
|
|
98
98
|
// Request timing middleware: logs method, url, status and elapsed ms
|
|
99
99
|
server.use((req, res, next) => {
|
|
@@ -101,7 +101,7 @@ if (process.env.mbkbucket_test === "dev") {
|
|
|
101
101
|
res.on('finish', () => {
|
|
102
102
|
const diff = process.hrtime(start);
|
|
103
103
|
const ms = diff[0] * 1000 + diff[1] / 1e6;
|
|
104
|
-
|
|
104
|
+
debugServer("[%s] %s %s %s - %s ms", new Date().toISOString(), req.method, req.originalUrl, res.statusCode, ms.toFixed(3));
|
|
105
105
|
});
|
|
106
106
|
next();
|
|
107
107
|
});
|
|
@@ -112,7 +112,7 @@ if (process.env.mbkbucket_test === "dev") {
|
|
|
112
112
|
|
|
113
113
|
// 404 handler
|
|
114
114
|
server.use((req, res) => {
|
|
115
|
-
|
|
115
|
+
debugServer("Path not found: %s %s", req.method, req.url);
|
|
116
116
|
return res.status(404).render("Error/dError.handlebars", {
|
|
117
117
|
layout: false,
|
|
118
118
|
code: 404,
|
|
@@ -138,8 +138,8 @@ if (process.env.mbkbucket_test === "dev") {
|
|
|
138
138
|
});
|
|
139
139
|
|
|
140
140
|
const port = process.env.PORT || 3004;
|
|
141
|
-
server.listen(port, async
|
|
142
|
-
|
|
141
|
+
server.listen(port, async() => {
|
|
142
|
+
debugServer("Server running on http://localhost:%s", port);
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
}
|
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
|
@@ -2,8 +2,10 @@ 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
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,6 +79,20 @@ 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
97
|
router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
77
98
|
try {
|
|
@@ -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({
|
|
@@ -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
|
|
|
@@ -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) {
|
|
@@ -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) {
|
|
@@ -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/view.js
CHANGED
|
@@ -3,6 +3,9 @@ import { validateSessionAndRole } 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();
|
|
@@ -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;
|
|
@@ -508,4 +528,4 @@ if (mbkbucketVar?.publiView_enabled) {
|
|
|
508
528
|
});
|
|
509
529
|
}
|
|
510
530
|
|
|
511
|
-
export default router;
|
|
531
|
+
export default router;
|