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.
@@ -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, '&lt;')
70
+ .replace(/>/g, '&gt;')
71
+ .replace(/"/g, '&quot;')
72
+ .replace(/'/g, '&#39;');
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.split('/').pop();
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: ${fileName}</title>
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">${fileName}</div>
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
- async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
164
- const fileName = keyToUse.split('/').pop();
165
- const fileExtension = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : '';
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
- console.log(`[mbkbucket] 📥 Request for ${fileName} (type: ${fileExtension}, range: ${req.headers.range || 'none'})`);
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 = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'css', 'js'].includes(fileExtension);
181
- const isVideo = ['mp4', 'webm', 'ogg', 'avi', 'mov'].includes(fileExtension);
182
- const isAudio = ['mp3', 'wav', 'flac', 'aac', 'm4a'].includes(fileExtension);
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
- console.log(`[mbkbucket] Processing range request: ${rangeHeader} for ${fileName}`);
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
- console.warn(`Range size mismatch for ${keyToUse}: expected=${chunksize} got=${rangeResult.ContentLength}`);
332
+ debugView('Range size mismatch for %s: expected=%s got=%s', keyToUse, chunksize, rangeResult.ContentLength);
256
333
  }
257
334
 
258
- console.log(`[mbkbucket] ✓ Serving range bytes ${start}-${end}/${total} (${chunksize} bytes) for ${fileName}`);
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', 'public, max-age=86400, must-revalidate');
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
- console.log(`[mbkbucket] Serving full PDF (no range header) for ${fileName}`);
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
- if (isStaticAsset) {
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;