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/view.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { validateSessionAndRole } from "mbkauthe";
|
|
3
|
-
import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, generateSignedUrl } from "../s3.js";
|
|
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,9 +63,59 @@ 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
|
+
|
|
81
|
+
function classifyViewError(error, fallbackMessage = 'Request failed') {
|
|
82
|
+
const message = String(error?.message || fallbackMessage);
|
|
83
|
+
const lower = message.toLowerCase();
|
|
84
|
+
|
|
85
|
+
if (lower.includes('no bucket selected') || lower.includes('not found in bucketconnection')) {
|
|
86
|
+
return { status: 400, code: 'INVALID_BUCKET', message };
|
|
87
|
+
}
|
|
88
|
+
if (lower.includes('access denied')) {
|
|
89
|
+
return { status: 403, code: 'ACCESS_DENIED', message };
|
|
90
|
+
}
|
|
91
|
+
if (lower.includes('not found')) {
|
|
92
|
+
return { status: 404, code: 'NOT_FOUND', message };
|
|
93
|
+
}
|
|
94
|
+
if (lower.includes('invalid') || lower.includes('required')) {
|
|
95
|
+
return { status: 400, code: 'VALIDATION_ERROR', message };
|
|
96
|
+
}
|
|
97
|
+
return { status: 500, code: 'INTERNAL_ERROR', message };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sendViewError(res, error, fallbackMessage = 'Request failed') {
|
|
101
|
+
const mapped = classifyViewError(error, fallbackMessage);
|
|
102
|
+
return res.status(mapped.status).json({ code: mapped.code, message: mapped.message });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Central bucket guard for view routes except public view (forced default bucket).
|
|
106
|
+
router.use((req, res, next) => {
|
|
107
|
+
if (req.path.startsWith('/p_view/')) return next();
|
|
108
|
+
if (req.bucketResolveError) return sendViewError(res, req.bucketResolveError, 'Invalid bucket selection');
|
|
109
|
+
if (!req.activeBucket) {
|
|
110
|
+
return sendViewError(res, new Error('No bucket selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.'));
|
|
111
|
+
}
|
|
112
|
+
next();
|
|
113
|
+
});
|
|
114
|
+
|
|
66
115
|
// New: lightweight player page for robust playback in browsers
|
|
67
116
|
router.get('/player/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
68
117
|
try {
|
|
118
|
+
const bucketName = req.activeBucket;
|
|
69
119
|
const key = req.params.key;
|
|
70
120
|
let keyToUse;
|
|
71
121
|
try {
|
|
@@ -74,14 +124,16 @@ router.get('/player/:key(*)', validateSessionAndRole('SuperAdmin'), async (req,
|
|
|
74
124
|
return res.status(400).send(e.message);
|
|
75
125
|
}
|
|
76
126
|
const encoded = encodeURIComponent(keyToUse);
|
|
77
|
-
const fileName = keyToUse
|
|
127
|
+
const fileName = getBaseName(keyToUse);
|
|
128
|
+
const safeFileName = escapeHtml(fileName);
|
|
78
129
|
|
|
130
|
+
const bucketQuery = `?bucket=${encodeURIComponent(bucketName)}`;
|
|
79
131
|
const html = `<!doctype html>
|
|
80
132
|
<html lang="en">
|
|
81
133
|
<head>
|
|
82
134
|
<meta charset="utf-8">
|
|
83
135
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
84
|
-
<title>Preview: ${
|
|
136
|
+
<title>Preview: ${safeFileName}</title>
|
|
85
137
|
<style>
|
|
86
138
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
87
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; }
|
|
@@ -91,9 +143,9 @@ router.get('/player/:key(*)', validateSessionAndRole('SuperAdmin'), async (req,
|
|
|
91
143
|
</style>
|
|
92
144
|
</head>
|
|
93
145
|
<body>
|
|
94
|
-
<div class="file-name">${
|
|
146
|
+
<div class="file-name">${safeFileName}</div>
|
|
95
147
|
<div class="player-container">
|
|
96
|
-
<video controls preload="metadata" src="/mbkbucket/view/${encoded}">
|
|
148
|
+
<video controls preload="metadata" src="/mbkbucket/view/${encoded}${bucketQuery}">
|
|
97
149
|
Your browser does not support the video tag.
|
|
98
150
|
</video>
|
|
99
151
|
</div>
|
|
@@ -123,6 +175,9 @@ const VIEWABLE_TYPES = new Set([
|
|
|
123
175
|
]);
|
|
124
176
|
// Pre-sorted for 415 error responses (avoids re-sorting on every error)
|
|
125
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']);
|
|
126
181
|
|
|
127
182
|
const MIME_TYPES = {
|
|
128
183
|
// Images
|
|
@@ -160,11 +215,33 @@ const MIME_TYPES = {
|
|
|
160
215
|
// Options:
|
|
161
216
|
// noindex {boolean} – adds X-Robots-Tag: noindex, nofollow (default false)
|
|
162
217
|
// ---------------------------------------------------------------------------
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
218
|
+
function isSensitiveTextType(contentType = '') {
|
|
219
|
+
const type = String(contentType || '').toLowerCase();
|
|
220
|
+
return type.startsWith('text/')
|
|
221
|
+
|| type.includes('json')
|
|
222
|
+
|| type.includes('xml')
|
|
223
|
+
|| type.includes('javascript')
|
|
224
|
+
|| type.includes('typescript');
|
|
225
|
+
}
|
|
166
226
|
|
|
167
|
-
|
|
227
|
+
function buildCacheControl(contentType, { isStaticAsset = false, supportsRanges = false, publicCache = false } = {}) {
|
|
228
|
+
if (publicCache) {
|
|
229
|
+
if (isStaticAsset) return 'public, max-age=3600, immutable';
|
|
230
|
+
if (supportsRanges) return 'public, max-age=86400, must-revalidate';
|
|
231
|
+
return 'public, max-age=300, must-revalidate';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (isSensitiveTextType(contentType)) return 'private, no-store';
|
|
235
|
+
if (supportsRanges) return 'private, max-age=1800, must-revalidate';
|
|
236
|
+
return 'private, max-age=300, must-revalidate';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName, publicCache = false } = {}) {
|
|
240
|
+
const fileName = getBaseName(keyToUse);
|
|
241
|
+
const dotIndex = fileName.lastIndexOf('.');
|
|
242
|
+
const fileExtension = dotIndex === -1 ? '' : fileName.substring(dotIndex + 1).toLowerCase();
|
|
243
|
+
|
|
244
|
+
debugView('Request for %s (type: %s, range: %s)', fileName, fileExtension, req.headers.range || 'none');
|
|
168
245
|
|
|
169
246
|
if (!VIEWABLE_TYPES.has(fileExtension)) {
|
|
170
247
|
return res.status(415).json({
|
|
@@ -177,9 +254,9 @@ async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
|
|
|
177
254
|
const ifModifiedSince = req.headers['if-modified-since'];
|
|
178
255
|
const rangeHeader = req.headers.range;
|
|
179
256
|
|
|
180
|
-
const isStaticAsset =
|
|
181
|
-
const isVideo =
|
|
182
|
-
const isAudio =
|
|
257
|
+
const isStaticAsset = STATIC_ASSET_TYPES.has(fileExtension);
|
|
258
|
+
const isVideo = VIDEO_TYPES.has(fileExtension);
|
|
259
|
+
const isAudio = AUDIO_TYPES.has(fileExtension);
|
|
183
260
|
const isPdf = fileExtension === 'pdf';
|
|
184
261
|
const supportsRanges = isVideo || isAudio || isPdf;
|
|
185
262
|
const contentType = MIME_TYPES[fileExtension] || 'application/octet-stream';
|
|
@@ -190,10 +267,10 @@ async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
|
|
|
190
267
|
// requested byte range — avoids downloading the full file first.
|
|
191
268
|
// ---------------------------------------------------------------------------
|
|
192
269
|
if (supportsRanges && rangeHeader) {
|
|
193
|
-
|
|
270
|
+
debugView('Processing range request: %s for %s', rangeHeader, fileName);
|
|
194
271
|
let meta;
|
|
195
272
|
try {
|
|
196
|
-
meta = await getFileMetadata(keyToUse);
|
|
273
|
+
meta = await getFileMetadata(keyToUse, bucketName);
|
|
197
274
|
} catch (err) {
|
|
198
275
|
console.error('[mbkbucket] serveFileInline metadata error:', err);
|
|
199
276
|
return res.status(500).json({ message: 'Failed to fetch file metadata', error: err.message });
|
|
@@ -249,19 +326,19 @@ async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
|
|
|
249
326
|
const chunksize = end - start + 1;
|
|
250
327
|
|
|
251
328
|
try {
|
|
252
|
-
const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}
|
|
329
|
+
const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, bucketName });
|
|
253
330
|
|
|
254
331
|
if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
|
|
255
|
-
|
|
332
|
+
debugView('Range size mismatch for %s: expected=%s got=%s', keyToUse, chunksize, rangeResult.ContentLength);
|
|
256
333
|
}
|
|
257
334
|
|
|
258
|
-
|
|
335
|
+
debugView('Serving range bytes %s-%s/%s (%s bytes) for %s', start, end, total, chunksize, fileName);
|
|
259
336
|
|
|
260
337
|
res.status(206);
|
|
261
338
|
res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
|
|
262
339
|
res.setHeader('Content-Length', chunksize);
|
|
263
340
|
res.setHeader('Content-Type', contentType);
|
|
264
|
-
res.setHeader('Cache-Control',
|
|
341
|
+
res.setHeader('Cache-Control', buildCacheControl(contentType, { supportsRanges: true, publicCache }));
|
|
265
342
|
res.setHeader('ETag', etag);
|
|
266
343
|
res.setHeader('Accept-Ranges', 'bytes');
|
|
267
344
|
if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
|
|
@@ -308,11 +385,12 @@ async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
|
|
|
308
385
|
// ---------------------------------------------------------------------------
|
|
309
386
|
const downloadOptions = {
|
|
310
387
|
...(ifNoneMatch && { ifNoneMatch }),
|
|
311
|
-
...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) })
|
|
388
|
+
...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) }),
|
|
389
|
+
bucketName
|
|
312
390
|
};
|
|
313
391
|
|
|
314
392
|
if (isPdf) {
|
|
315
|
-
|
|
393
|
+
debugView('Serving full PDF (no range header) for %s', fileName);
|
|
316
394
|
}
|
|
317
395
|
|
|
318
396
|
let result;
|
|
@@ -346,14 +424,7 @@ async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
|
|
|
346
424
|
}
|
|
347
425
|
if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
|
|
348
426
|
|
|
349
|
-
|
|
350
|
-
res.setHeader('Cache-Control', 'public, max-age=3600, immutable');
|
|
351
|
-
} else if (supportsRanges) {
|
|
352
|
-
// For video, audio, and PDF - enable caching with revalidation
|
|
353
|
-
res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate');
|
|
354
|
-
} else {
|
|
355
|
-
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
356
|
-
}
|
|
427
|
+
res.setHeader('Cache-Control', buildCacheControl(contentType, { isStaticAsset, supportsRanges, publicCache }));
|
|
357
428
|
|
|
358
429
|
res.status(200);
|
|
359
430
|
|
|
@@ -393,6 +464,7 @@ async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
|
|
|
393
464
|
|
|
394
465
|
router.get('/view/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
395
466
|
try {
|
|
467
|
+
const bucketName = req.activeBucket;
|
|
396
468
|
let keyToUse;
|
|
397
469
|
try {
|
|
398
470
|
keyToUse = ensureKeyHasAppPrefix(req.params.key);
|
|
@@ -400,7 +472,7 @@ router.get('/view/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, re
|
|
|
400
472
|
return res.status(400).json({ message: e.message });
|
|
401
473
|
}
|
|
402
474
|
|
|
403
|
-
await serveFileInline(req, res, keyToUse, { noindex: true });
|
|
475
|
+
await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: false });
|
|
404
476
|
|
|
405
477
|
} catch (error) {
|
|
406
478
|
console.error("Error viewing file:", error);
|
|
@@ -417,6 +489,7 @@ router.get('/view/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, re
|
|
|
417
489
|
if (mbkbucketVar?.publiView_enabled) {
|
|
418
490
|
router.get('/p_view/:key(*)', pviewRateLimit, pviewSecurity, async (req, res) => {
|
|
419
491
|
try {
|
|
492
|
+
const bucketName = resolveBucketName();
|
|
420
493
|
const key = req.params.key;
|
|
421
494
|
|
|
422
495
|
// No directory indexing — reject folder-like or empty keys
|
|
@@ -438,7 +511,7 @@ if (mbkbucketVar?.publiView_enabled) {
|
|
|
438
511
|
if (!p_view_inline) {
|
|
439
512
|
// Signed URL mode — redirect to a pre-signed S3 URL (1 hour expiry)
|
|
440
513
|
try {
|
|
441
|
-
const signed = await generateSignedUrl(keyToUse, 'getObject', 3600);
|
|
514
|
+
const signed = await generateSignedUrl(keyToUse, 'getObject', 3600, bucketName);
|
|
442
515
|
return res.redirect(302, signed.url);
|
|
443
516
|
} catch (err) {
|
|
444
517
|
console.error('[mbkbucket] p_view signed URL error:', err);
|
|
@@ -447,7 +520,7 @@ if (mbkbucketVar?.publiView_enabled) {
|
|
|
447
520
|
}
|
|
448
521
|
|
|
449
522
|
// Direct serve mode — stream file inline (with noindex header)
|
|
450
|
-
await serveFileInline(req, res, keyToUse, { noindex: true });
|
|
523
|
+
await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: true });
|
|
451
524
|
} catch (error) {
|
|
452
525
|
console.error('[mbkbucket] p_view error:', error);
|
|
453
526
|
res.status(500).json({ message: 'Internal server error', error: error.message });
|
|
@@ -455,4 +528,4 @@ if (mbkbucketVar?.publiView_enabled) {
|
|
|
455
528
|
});
|
|
456
529
|
}
|
|
457
530
|
|
|
458
|
-
export default router;
|
|
531
|
+
export default router;
|