mbkbucket 4.2.0 → 4.4.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.
@@ -478,7 +478,6 @@
478
478
  gap: 7px;
479
479
  }
480
480
  .toast-btn:active { transform: scale(0.97); }
481
- .toast-btn:hover { transform: translateY(-1px); }
482
481
 
483
482
  .tb-success { background: rgba(22,163,74,0.08); border-color: rgba(22,163,74,0.25); color: var(--success-color-ml); }
484
483
  .tb-error { background: rgba(220,38,38,0.08); border-color: rgba(220,38,38,0.25); color: var(--danger-color-ml); }
@@ -477,7 +477,6 @@
477
477
  }
478
478
  .mbkh-item:hover {
479
479
  border-color: var(--mbkh-primary);
480
- transform: translateY(-2px);
481
480
  box-shadow: 0 6px 16px rgba(15, 118, 110, 0.1);
482
481
  background: #ffffff;
483
482
  }
@@ -871,7 +870,6 @@
871
870
  .mbkh-btn-primary:hover {
872
871
  background: var(--mbkh-primary-dk);
873
872
  border-color: var(--mbkh-primary-dk);
874
- transform: translateY(-1px);
875
873
  color: #fff;
876
874
  }
877
875
  .mbkh-btn-primary:disabled {
package/lib/src/app.js CHANGED
@@ -68,7 +68,9 @@ export function createApp() {
68
68
  additionalViewsDirs,
69
69
  additionalPartialsDirs,
70
70
  helpers: {
71
- mbkbucket_cachebuster: () => `?v=${packageJson.version}`
71
+ bcachebust: (version) => `?v=${(version && typeof version === "string" ? version : packageJson.version)}`,
72
+ cachebust: (version) => `?v=${(version && typeof version === "string" ? version : packageJson.version)}`,
73
+ mbkbucket_cachebuster: (version) => `?v=${(version && typeof version === "string" ? version : packageJson.version)}`
72
74
  }
73
75
  });
74
76
 
@@ -3,7 +3,7 @@ import { listfiles, uploadFile, deleteFile, deleteFiles, deleteFolder, createFol
3
3
  import { metricsRegistry } from "../core/metrics.js";
4
4
  import { sendApiError, classifyApiError } from "../utils/errors.js";
5
5
  import { getBaseName } from "mbkutils";
6
- import { isSensitiveTextType, buildKey, normalizeKeyParam, getFolderPath, destroyStream } from "../utils/helpers.js";
6
+ import { isSensitiveTextType, buildKey, normalizeKeyParam, getFolderPath, destroyStream, parseRangeHeader } from "../utils/helpers.js";
7
7
  import { createLogger } from "#logger";
8
8
 
9
9
 
@@ -376,22 +376,130 @@ export async function downloadFileHandler(req, res) {
376
376
 
377
377
  if (aborted) return;
378
378
 
379
- const result = await downloadFile(keyToUse, { bucketName: req.activeBucket });
379
+ const rawFileName = getBaseName(keyToUse);
380
+ const safeAsciiName = rawFileName.replace(/["\\]/g, '').replace(/[^\x20-\x7E]/g, '_');
381
+ const encodedName = encodeURIComponent(rawFileName);
382
+ const ifNoneMatch = req.headers['if-none-match'];
383
+ const ifModifiedSince = req.headers['if-modified-since'];
384
+ const rangeHeader = req.headers.range;
385
+
386
+ // Fast path for HEAD requests
387
+ if (req.method === 'HEAD') {
388
+ let meta;
389
+ try {
390
+ meta = await getFileMetadata(keyToUse, req.activeBucket);
391
+ } catch (err) {
392
+ if (!res.headersSent) res.status(500).end();
393
+ return;
394
+ }
395
+
396
+ if (!meta?.exists) return res.status(404).end();
397
+
398
+ const etag = meta.ETag || `"${new Date(meta.LastModified || Date.now()).getTime()}-${meta.ContentLength || 0}"`;
399
+ if (ifNoneMatch && (ifNoneMatch === etag || ifNoneMatch === '*')) return res.status(304).end();
400
+ if (ifModifiedSince && meta.LastModified && new Date(meta.LastModified) <= new Date(ifModifiedSince)) {
401
+ return res.status(304).end();
402
+ }
403
+
404
+ const contentType = meta.ContentType || 'application/octet-stream';
405
+ const isSensitive = isSensitiveTextType(contentType);
406
+ const isLargeFile = Number(meta.ContentLength || 0) > 50 * 1024 * 1024;
407
+
408
+ res.status(200);
409
+ res.setHeader('Content-Disposition', `attachment; filename="${safeAsciiName}"; filename*=UTF-8''${encodedName}`);
410
+ res.setHeader('Content-Type', contentType);
411
+ if (meta.ContentLength) res.setHeader('Content-Length', meta.ContentLength);
412
+ res.setHeader('ETag', etag);
413
+ if (meta.LastModified) res.setHeader('Last-Modified', new Date(meta.LastModified).toUTCString());
414
+ res.setHeader('Accept-Ranges', 'bytes');
415
+ res.setHeader('Vary', 'Accept-Encoding, Range');
416
+ res.setHeader('Cache-Control', isSensitive ? 'private, no-store' : isLargeFile ? 'private, max-age=600, must-revalidate' : 'private, max-age=1800, must-revalidate');
417
+ return res.end();
418
+ }
419
+
420
+ // Range request support for multi-part download acceleration / resumption
421
+ if (rangeHeader) {
422
+ let meta;
423
+ try {
424
+ meta = await getFileMetadata(keyToUse, req.activeBucket);
425
+ } catch (err) {
426
+ if (!res.headersSent) res.status(500).json({ message: 'Failed to fetch metadata', error: err.message });
427
+ return;
428
+ }
429
+
430
+ if (!meta?.exists) return res.status(404).json({ message: 'File not found', key: req.params.key });
431
+
432
+ const total = Number(meta.ContentLength);
433
+ if (total) {
434
+ const range = parseRangeHeader(rangeHeader, total);
435
+ if (!range) {
436
+ res.status(416).setHeader('Content-Range', `bytes */${total}`);
437
+ return res.end();
438
+ }
439
+
440
+ const { start, end, size: chunksize } = range;
441
+ const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, bucketName: req.activeBucket });
442
+ const stream = rangeResult.Body || rangeResult.stream;
443
+ if (aborted) {
444
+ destroyStream(stream);
445
+ return;
446
+ }
447
+
448
+ const etag = meta.ETag || `"${new Date(meta.LastModified || Date.now()).getTime()}-${meta.ContentLength || 0}"`;
449
+ const contentType = meta.ContentType || rangeResult.ContentType || 'application/octet-stream';
450
+
451
+ res.status(206);
452
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
453
+ res.setHeader('Content-Length', chunksize);
454
+ res.setHeader('Content-Type', contentType);
455
+ res.setHeader('Content-Disposition', `attachment; filename="${safeAsciiName}"; filename*=UTF-8''${encodedName}`);
456
+ res.setHeader('ETag', etag);
457
+ res.setHeader('Accept-Ranges', 'bytes');
458
+ res.setHeader('Vary', 'Accept-Encoding, Range');
459
+ res.setHeader('Cache-Control', 'private, max-age=1800, must-revalidate');
460
+
461
+ try {
462
+ await pipeline(stream, res);
463
+ } catch (pipeErr) {
464
+ if (aborted) {
465
+ destroyStream(stream);
466
+ return;
467
+ }
468
+ throw pipeErr;
469
+ }
470
+ return;
471
+ }
472
+ }
473
+
474
+ // Standard full-file download
475
+ const downloadOptions = {
476
+ ...(ifNoneMatch && { ifNoneMatch }),
477
+ ...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) }),
478
+ bucketName: req.activeBucket
479
+ };
480
+
481
+ const result = await downloadFile(keyToUse, downloadOptions);
482
+ if (result?.notModified) return res.status(304).end();
483
+
380
484
  const stream = result.Body || result.stream;
381
485
  if (aborted) {
382
486
  destroyStream(stream);
383
487
  return;
384
488
  }
385
489
 
386
- const fileName = getBaseName(keyToUse).replace(/"/g, '\\"');
387
490
  const contentType = result.ContentType || 'application/octet-stream';
388
491
  const contentLength = result.ContentLength;
492
+ const etag = result.ETag || `"${new Date(result.LastModified || Date.now()).getTime()}-${contentLength || 0}"`;
493
+
494
+ if (ifNoneMatch === etag) return res.status(304).end();
389
495
 
390
- res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
496
+ res.setHeader('Content-Disposition', `attachment; filename="${safeAsciiName}"; filename*=UTF-8''${encodedName}`);
391
497
  res.setHeader('Content-Type', contentType);
392
498
  if (contentLength) res.setHeader('Content-Length', contentLength);
393
- if (result.ETag) res.setHeader('ETag', result.ETag);
499
+ res.setHeader('ETag', etag);
394
500
  if (result.LastModified) res.setHeader('Last-Modified', new Date(result.LastModified).toUTCString());
501
+ res.setHeader('Accept-Ranges', 'bytes');
502
+ res.setHeader('Vary', 'Accept-Encoding, Range');
395
503
 
396
504
  const isSensitive = isSensitiveTextType(contentType);
397
505
  const isLargeFile = Number(contentLength || 0) > 50 * 1024 * 1024;
@@ -11,7 +11,10 @@ function attachStreamCleanup(req, res, stream, timeoutMs = 120000) {
11
11
  const cleanup = () => destroyStream(stream);
12
12
 
13
13
  stream.on('error', (err) => {
14
- console.error('Stream error:', err);
14
+ const isAbort = err.name === 'AbortError' || err.code === 'ERR_STREAM_PREMATURE_CLOSE' || err.message?.includes('premature close');
15
+ if (!isAbort) {
16
+ console.error('Stream error:', err);
17
+ }
15
18
  cleanup();
16
19
  if (!res.headersSent) res.status(500).end('Stream error');
17
20
  });
@@ -19,7 +22,6 @@ function attachStreamCleanup(req, res, stream, timeoutMs = 120000) {
19
22
  req.on('close', cleanup);
20
23
  req.on('aborted', cleanup);
21
24
  req.setTimeout(timeoutMs, () => {
22
- console.error('Request timeout for stream');
23
25
  cleanup();
24
26
  if (!res.headersSent) res.status(408).end('Request timeout');
25
27
  });
@@ -27,14 +29,28 @@ function attachStreamCleanup(req, res, stream, timeoutMs = 120000) {
27
29
  res.on('close', cleanup);
28
30
  }
29
31
 
32
+ function isIfRangeValid(validator, etag, lastModified) {
33
+ if (!validator) return true;
34
+ const cleanValidator = validator.trim();
35
+ if (cleanValidator.startsWith('"') || cleanValidator.startsWith('W/"')) {
36
+ return cleanValidator === etag || cleanValidator === `W/${etag}`;
37
+ }
38
+ if (lastModified) {
39
+ const validatorDate = new Date(cleanValidator).getTime();
40
+ const modDate = new Date(lastModified).getTime();
41
+ return !isNaN(validatorDate) && modDate <= validatorDate;
42
+ }
43
+ return false;
44
+ }
30
45
 
31
46
  async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName, publicCache = false } = {}) {
32
47
  const fileName = getBaseName(keyToUse);
33
48
  const fileExtension = getFileExt(fileName);
49
+ const isDownload = req.query?.download === 'true' || req.query?.download === '1' || req.query?.dl === '1' || req.query?.dl === 'true';
34
50
 
35
- debugView('Request for %s (type: %s, range: %s)', fileName, fileExtension, req.headers.range || 'none');
51
+ debugView('Request for %s (type: %s, range: %s, download: %s)', fileName, fileExtension, req.headers.range || 'none', isDownload);
36
52
 
37
- if (!VIEWABLE_TYPES.has(fileExtension)) {
53
+ if (!isDownload && !VIEWABLE_TYPES.has(fileExtension)) {
38
54
  return res.status(415).json({
39
55
  message: 'File type not supported for viewing',
40
56
  supportedTypes: VIEWABLE_TYPES_SORTED
@@ -43,6 +59,7 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
43
59
 
44
60
  const ifNoneMatch = req.headers['if-none-match'];
45
61
  const ifModifiedSince = req.headers['if-modified-since'];
62
+ const ifRange = req.headers['if-range'];
46
63
  const rangeHeader = req.headers.range;
47
64
 
48
65
  const isStaticAsset = STATIC_ASSET_TYPES.has(fileExtension);
@@ -53,6 +70,47 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
53
70
  const contentType = getMimeType(fileName);
54
71
  const safeAsciiName = fileName.replace(/["\\]/g, '').replace(/[^\x20-\x7E]/g, '_');
55
72
  const encodedName = encodeURIComponent(fileName);
73
+ const dispositionType = isDownload ? 'attachment' : 'inline';
74
+
75
+ // Fast path for HEAD requests (Vercel & CDN friendly: 0 bytes downloaded)
76
+ if (req.method === 'HEAD') {
77
+ debugView('Processing HEAD request for %s', fileName);
78
+ let meta;
79
+ try {
80
+ meta = await getFileMetadata(keyToUse, bucketName);
81
+ } catch (err) {
82
+ console.error('[mbkbucket] serveFileInline HEAD metadata error:', err);
83
+ return res.status(500).end();
84
+ }
85
+
86
+ if (!meta?.exists) return res.status(404).end();
87
+
88
+ const etag = meta.ETag || `"${new Date(meta.LastModified || Date.now()).getTime()}-${meta.ContentLength || 0}"`;
89
+ if (ifNoneMatch && (ifNoneMatch === etag || ifNoneMatch === '*')) return res.status(304).end();
90
+ if (ifModifiedSince && meta.LastModified && new Date(meta.LastModified) <= new Date(ifModifiedSince)) {
91
+ return res.status(304).end();
92
+ }
93
+
94
+ res.status(200);
95
+ res.setHeader('Content-Type', contentType);
96
+ if (meta.ContentLength) res.setHeader('Content-Length', meta.ContentLength);
97
+ res.setHeader('Content-Disposition', `${dispositionType}; filename="${safeAsciiName}"; filename*=UTF-8''${encodedName}`);
98
+ res.setHeader('ETag', etag);
99
+ if (meta.LastModified) res.setHeader('Last-Modified', new Date(meta.LastModified).toUTCString());
100
+ if (supportsRanges) {
101
+ res.setHeader('Accept-Ranges', 'bytes');
102
+ res.setHeader('Vary', 'Accept-Encoding, Range');
103
+ }
104
+ res.setHeader('Cache-Control', buildCacheControl(contentType, { isStaticAsset, supportsRanges, publicCache }));
105
+ if (publicCache) {
106
+ res.setHeader('CDN-Cache-Control', supportsRanges ? 'public, s-maxage=31536000, stale-while-revalidate=86400' : 'public, s-maxage=86400, stale-while-revalidate=3600');
107
+ res.setHeader('Vercel-CDN-Cache-Control', supportsRanges ? 'public, s-maxage=31536000, stale-while-revalidate=86400' : 'public, s-maxage=86400, stale-while-revalidate=3600');
108
+ }
109
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
110
+ if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
111
+ res.setHeader('X-Content-Type-Options', 'nosniff');
112
+ return res.end();
113
+ }
56
114
 
57
115
  // Fast path for range requests (video / audio / PDF streaming)
58
116
  if (supportsRanges && rangeHeader) {
@@ -67,52 +125,61 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
67
125
 
68
126
  if (!meta?.exists) return res.status(404).json({ message: 'File not found', key: keyToUse });
69
127
 
70
- const etag = `"${new Date(meta.LastModified || Date.now()).getTime()}-${meta.ContentLength || 0}"`;
71
- if (ifNoneMatch === etag) return res.status(304).end();
128
+ const etag = meta.ETag || `"${new Date(meta.LastModified || Date.now()).getTime()}-${meta.ContentLength || 0}"`;
129
+ if (ifNoneMatch && (ifNoneMatch === etag || ifNoneMatch === '*')) return res.status(304).end();
72
130
 
73
- const total = Number(meta.ContentLength);
74
- if (total) {
75
- const range = parseRangeHeader(rangeHeader, total);
76
- if (!range) {
77
- res.status(416).setHeader('Content-Range', `bytes */${total}`);
78
- return res.end();
79
- }
131
+ const ifRangeSatisfied = !ifRange || isIfRangeValid(ifRange, etag, meta.LastModified);
80
132
 
81
- const { start, end, size: chunksize } = range;
82
- try {
83
- const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, bucketName });
84
- if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
85
- debugView('Range size mismatch for %s: expected=%s got=%s', keyToUse, chunksize, rangeResult.ContentLength);
133
+ if (ifRangeSatisfied) {
134
+ const total = Number(meta.ContentLength);
135
+ if (total) {
136
+ const range = parseRangeHeader(rangeHeader, total);
137
+ if (!range) {
138
+ res.status(416).setHeader('Content-Range', `bytes */${total}`);
139
+ return res.end();
86
140
  }
87
141
 
88
- debugView('Serving range bytes %s-%s/%s (%s bytes) for %s', start, end, total, chunksize, fileName);
89
-
90
- res.status(206);
91
- res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
92
- res.setHeader('Content-Length', chunksize);
93
- res.setHeader('Content-Type', contentType);
94
- res.setHeader('Content-Disposition', `inline; filename="${safeAsciiName}"; filename*=UTF-8''${encodedName}`);
95
- res.setHeader('Cache-Control', buildCacheControl(contentType, { supportsRanges: true, publicCache }));
96
- res.setHeader('ETag', etag);
97
- res.setHeader('Accept-Ranges', 'bytes');
98
- res.setHeader('X-Frame-Options', 'SAMEORIGIN');
99
- if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
100
- res.setHeader('X-Content-Type-Options', 'nosniff');
101
- const stream = rangeResult.Body || rangeResult.stream;
102
- attachStreamCleanup(req, res, stream);
103
- return stream.pipe(res);
104
- } catch (rangeError) {
105
- console.error('[mbkbucket] Range request failed for %s:', keyToUse, rangeError);
106
- if (!res.headersSent) {
107
- return res.status(500).json({ message: 'Range stream failed', error: rangeError.message });
142
+ const { start, end, size: chunksize } = range;
143
+ try {
144
+ const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, bucketName });
145
+ if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
146
+ debugView('Range size mismatch for %s: expected=%s got=%s', keyToUse, chunksize, rangeResult.ContentLength);
147
+ }
148
+
149
+ debugView('Serving range bytes %s-%s/%s (%s bytes) for %s', start, end, total, chunksize, fileName);
150
+
151
+ res.status(206);
152
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
153
+ res.setHeader('Content-Length', chunksize);
154
+ res.setHeader('Content-Type', contentType);
155
+ res.setHeader('Content-Disposition', `${dispositionType}; filename="${safeAsciiName}"; filename*=UTF-8''${encodedName}`);
156
+ res.setHeader('Cache-Control', buildCacheControl(contentType, { supportsRanges: true, publicCache }));
157
+ if (publicCache) {
158
+ res.setHeader('CDN-Cache-Control', 'public, s-maxage=31536000, stale-while-revalidate=86400');
159
+ res.setHeader('Vercel-CDN-Cache-Control', 'public, s-maxage=31536000, stale-while-revalidate=86400');
160
+ }
161
+ res.setHeader('ETag', etag);
162
+ res.setHeader('Accept-Ranges', 'bytes');
163
+ res.setHeader('Vary', 'Accept-Encoding, Range');
164
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
165
+ if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
166
+ res.setHeader('X-Content-Type-Options', 'nosniff');
167
+ const stream = rangeResult.Body || rangeResult.stream;
168
+ attachStreamCleanup(req, res, stream);
169
+ return stream.pipe(res);
170
+ } catch (rangeError) {
171
+ console.error('[mbkbucket] Range request failed for %s:', keyToUse, rangeError);
172
+ if (!res.headersSent) {
173
+ return res.status(500).json({ message: 'Range stream failed', error: rangeError.message });
174
+ }
175
+ return;
108
176
  }
109
- return;
110
177
  }
111
178
  }
112
179
  }
113
180
 
114
181
 
115
- // Standard path: full-file download
182
+ // Standard path: full-file download/view
116
183
  const downloadOptions = {
117
184
  ...(ifNoneMatch && { ifNoneMatch }),
118
185
  ...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) }),
@@ -137,11 +204,11 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
137
204
 
138
205
  if (result?.notModified) return res.status(304).end();
139
206
 
140
- const etag = `"${new Date(result.LastModified || Date.now()).getTime()}-${result.ContentLength || 0}"`;
141
- if (ifNoneMatch === etag) return res.status(304).end();
207
+ const etag = result.ETag || `"${new Date(result.LastModified || Date.now()).getTime()}-${result.ContentLength || 0}"`;
208
+ if (ifNoneMatch && (ifNoneMatch === etag || ifNoneMatch === '*')) return res.status(304).end();
142
209
 
143
210
  res.setHeader('Content-Type', contentType);
144
- res.setHeader('Content-Disposition', `inline; filename="${safeAsciiName}"; filename*=UTF-8''${encodedName}`);
211
+ res.setHeader('Content-Disposition', `${dispositionType}; filename="${safeAsciiName}"; filename*=UTF-8''${encodedName}`);
145
212
  res.setHeader('ETag', etag);
146
213
  res.setHeader('X-Content-Type-Options', 'nosniff');
147
214
  res.setHeader('X-Frame-Options', 'SAMEORIGIN');
@@ -152,8 +219,15 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
152
219
  if (fileExtension === 'html' || fileExtension === 'htm') {
153
220
  res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
154
221
  }
155
- if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
222
+ if (supportsRanges) {
223
+ res.setHeader('Accept-Ranges', 'bytes');
224
+ res.setHeader('Vary', 'Accept-Encoding, Range');
225
+ }
156
226
  res.setHeader('Cache-Control', buildCacheControl(contentType, { isStaticAsset, supportsRanges, publicCache }));
227
+ if (publicCache) {
228
+ res.setHeader('CDN-Cache-Control', supportsRanges ? 'public, s-maxage=31536000, stale-while-revalidate=86400' : 'public, s-maxage=86400, stale-while-revalidate=3600');
229
+ res.setHeader('Vercel-CDN-Cache-Control', supportsRanges ? 'public, s-maxage=31536000, stale-while-revalidate=86400' : 'public, s-maxage=86400, stale-while-revalidate=3600');
230
+ }
157
231
  res.status(200);
158
232
 
159
233
  const stream = result.Body || result.stream;
@@ -1,5 +1,6 @@
1
1
  import rateLimit from "express-rate-limit";
2
2
  import { renderPage } from "mbkutils";
3
+ import { mbkbucketVar } from "../config/index.js";
3
4
 
4
5
  export const generalLimiter = rateLimit({
5
6
  windowMs: 2 * 60 * 1000,
@@ -14,9 +15,10 @@ export const generalLimiter = rateLimit({
14
15
 
15
16
  export const pviewRateLimit = rateLimit({
16
17
  windowMs: 60 * 1000,
17
- max: 10,
18
+ max: () => Number(mbkbucketVar?.pview_rate_limit) || 300,
18
19
  standardHeaders: 'draft-7',
19
20
  legacyHeaders: false,
21
+ skip: (req) => Boolean(req.headers.range) || req.method === 'HEAD' || req.method === 'OPTIONS',
20
22
  keyGenerator: req => req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown',
21
23
  handler: (_req, res) => {
22
24
  res.status(429).json({ message: 'Too many requests. Please slow down and try again later.' });
@@ -3,19 +3,52 @@
3
3
  * Blocks bot/scraper User-Agents and sets security headers.
4
4
  */
5
5
 
6
+ import { mbkbucketVar } from "../config/index.js";
7
+
6
8
  const BLOCKED_UA_PATTERNS = /curl|wget|python-requests|python-urllib|go-http|scrapy|libwww|java\/|bot|crawl|spider|headless|phantom|selenium|puppeteer|playwright|postman|insomnia|httpie/i;
7
9
  const SUSPICIOUS_HEADERS = ['x-devtools-emulate-network-conditions-client-id', 'x-chromedriver-clientid', 'x-automated-tool'];
8
10
 
9
11
  export function pviewSecurity(req, res, next) {
10
12
  const ua = req.headers['user-agent'] || '';
11
13
 
12
- if (!ua || BLOCKED_UA_PATTERNS.test(ua) || !req.headers.accept || SUSPICIOUS_HEADERS.some(h => h in req.headers)) {
14
+ if (!ua || BLOCKED_UA_PATTERNS.test(ua) || SUSPICIOUS_HEADERS.some(h => h in req.headers)) {
15
+ return res.status(403).json({ message: 'Forbidden' });
16
+ }
17
+
18
+ // Handle OPTIONS preflight immediately
19
+ if (req.method === 'OPTIONS') {
20
+ if (mbkbucketVar?.p_view_cors !== false) {
21
+ const allowedOrigin = mbkbucketVar?.p_view_cors_origin || '*';
22
+ res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
23
+ res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
24
+ res.setHeader('Access-Control-Allow-Headers', 'Range, If-Range, If-None-Match, If-Modified-Since, Accept, Content-Type');
25
+ res.setHeader('Access-Control-Expose-Headers', 'Content-Range, Content-Length, Accept-Ranges, ETag, Last-Modified, Content-Type');
26
+ res.setHeader('Access-Control-Max-Age', '86400');
27
+ }
28
+ return res.status(204).end();
29
+ }
30
+
31
+ // Require Accept header only for standard non-range, non-HEAD requests
32
+ if (!req.headers.accept && !req.headers.range && req.method !== 'HEAD') {
13
33
  return res.status(403).json({ message: 'Forbidden' });
14
34
  }
15
35
 
16
36
  res.setHeader('X-Content-Type-Options', 'nosniff');
17
- res.setHeader('X-Frame-Options', 'SAMEORIGIN');
18
37
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
19
- res.removeHeader('Access-Control-Allow-Origin');
38
+
39
+ // Enable CORS for public views so cross-origin web apps and PDF.js can stream files
40
+ if (mbkbucketVar?.p_view_cors !== false) {
41
+ const allowedOrigin = mbkbucketVar?.p_view_cors_origin || '*';
42
+ res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
43
+ res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
44
+ res.setHeader('Access-Control-Allow-Headers', 'Range, If-Range, If-None-Match, If-Modified-Since, Accept, Content-Type');
45
+ res.setHeader('Access-Control-Expose-Headers', 'Content-Range, Content-Length, Accept-Ranges, ETag, Last-Modified, Content-Type');
46
+ }
47
+
48
+ const frameOpt = mbkbucketVar?.p_view_frame_options || 'SAMEORIGIN';
49
+ if (frameOpt && frameOpt !== 'none' && frameOpt !== 'ALLOWALL') {
50
+ res.setHeader('X-Frame-Options', frameOpt);
51
+ }
52
+
20
53
  next();
21
54
  }
@@ -50,6 +50,7 @@ export function createApiRoutes({
50
50
  apiRouter.post('/move', uploadAuth, moveItem);
51
51
  apiRouter.post('/copy', uploadAuth, copyItem);
52
52
  apiRouter.get('/download/*key', viewAuth, downloadFileHandler);
53
+ apiRouter.head('/download/*key', viewAuth, downloadFileHandler);
53
54
 
54
55
  return apiRouter;
55
56
  }
@@ -15,9 +15,12 @@ export function createViewRoutes({
15
15
  viewRouter.use(requireBucketView);
16
16
  viewRouter.get('/player/*key', authorization, playerPage);
17
17
  viewRouter.get('/view/*key', authorization, viewFile);
18
+ viewRouter.head('/view/*key', authorization, viewFile);
18
19
 
19
20
  if (publiViewEnabled ?? mbkbucketVar?.publiView_enabled) {
21
+ viewRouter.options('/p_view/*key', pviewSecurity);
20
22
  viewRouter.get('/p_view/*key', pviewRateLimit, pviewSecurity, publicView);
23
+ viewRouter.head('/p_view/*key', pviewRateLimit, pviewSecurity, publicView);
21
24
  }
22
25
 
23
26
  return viewRouter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mbkbucket",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "description": "Flexible S3/R2/Google Drive/Local bucket management — library, Express router, and standalone server CLI for mbktech.org applications. Provides upload, download, listing, multipart utilities, and a standalone server runner with built-in configuration validation and health checks",
5
5
  "author": "Muhammad Bin Khalid",
6
6
  "homepage": "https://github.com/MIbnEKhalid/mbkbucket#readme",
package/views/bucket.hbs CHANGED
@@ -12,8 +12,8 @@
12
12
  <meta charset="UTF-8">
13
13
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
14
14
  <title>{{#if page}}{{page}} - {{/if}}Web Portal</title>
15
- <script src="/mbkauthe/main.js" defer></script>
16
- <link rel="icon" type="image/x-icon" href="/icon.svg">
15
+ <script src="/mbkauthe/main.js{{acachebust}}" defer></script>
16
+ <link rel="icon" type="image/x-icon" href="/icon.svg{{bcachebust}}">
17
17
  <style>
18
18
  :root {
19
19
  --primary-color-ml: #0f766e;
@@ -145,14 +145,14 @@
145
145
  }
146
146
  }
147
147
  </style>
148
- {{> showmessage }}
148
+ {{> mbkutils_showmessage }}
149
149
  </head>
150
150
 
151
151
  <body>
152
152
  <div class="main-content">
153
153
  <nav class="top-nav">
154
154
  <div class="logo" onclick="window.location='/mbkbucket'">
155
- <img src="/icon.svg" alt="Logo">
155
+ <img src="/icon.svg{{bcachebust}}" alt="Logo">
156
156
  <span>MBKBucket</span>
157
157
  </div>
158
158
 
@@ -1,4 +1,4 @@
1
- <link rel="stylesheet" href="/mbkbucket/bucketadmin.css{{mbkbucket_cachebuster}}">
1
+ <link rel="stylesheet" href="/mbkbucket/bucketadmin.css{{bcachebust}}">
2
2
  <div id="bucketAppData" data-app-name="{{APP_NAME}}" data-provider-type="{{providerType}}" data-public-view-enabled="{{publicViewEnabled}}" hidden></div>
3
3
 
4
4
  <div class="page-wrapper">
@@ -191,4 +191,4 @@
191
191
  <!-- Context Menu Element -->
192
192
  <div id="fm-context-menu" class="fm-context-menu" style="display: none;"></div>
193
193
 
194
- <script src="/mbkbucket/bucketadmin.js{{mbkbucket_cachebuster}}"></script>
194
+ <script src="/mbkbucket/bucketadmin.js{{bcachebust}}"></script>
@@ -8,13 +8,13 @@
8
8
  <!DOCTYPE html>
9
9
  <html lang="en">
10
10
 
11
- {{> head pageTitle="System Information" ogUrl="/mbkbucket/info"}}
11
+ {{> mbkutils_head pageTitle="System Information" ogUrl="/mbkbucket/info"}}
12
12
 
13
13
  <body>
14
- {{> header appName=APP_NAME customURL="/mbkbucket"}}
14
+ {{> mbkutils_header appName=APP_NAME customURL="/mbkbucket"}}
15
15
 
16
16
  <section class="login-container">
17
- {{> backgroundElements}}
17
+ {{> mbkutils_backgroundElements}}
18
18
 
19
19
  <div class="auth-card info-box">
20
20
  <div class="info-header">
@@ -130,6 +130,6 @@
130
130
  </div>
131
131
  </section>
132
132
 
133
- {{> versionInfo}}
133
+ {{> mbkutils_versionInfo}}
134
134
  </body>
135
135
  </html>