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.
- package/lib/public/mbkbucket-helper-demo.html +0 -1
- package/lib/public/mbkbucket-helper.css +0 -2
- package/lib/src/app.js +3 -1
- package/lib/src/controllers/api.controller.js +113 -5
- package/lib/src/controllers/view.controller.js +118 -44
- package/lib/src/middleware/rate-limiter.js +3 -1
- package/lib/src/middleware/security.js +36 -3
- package/lib/src/routes/api.routes.js +1 -0
- package/lib/src/routes/view.routes.js +3 -0
- package/package.json +1 -1
- package/views/bucket.hbs +4 -4
- package/views/bucketadmincontent.hbs +2 -2
- package/views/mbkbucket_info.hbs +4 -4
|
@@ -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
|
-
|
|
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
|
|
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="${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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',
|
|
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)
|
|
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:
|
|
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) ||
|
|
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
|
-
|
|
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.
|
|
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
|
-
{{>
|
|
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{{
|
|
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{{
|
|
194
|
+
<script src="/mbkbucket/bucketadmin.js{{bcachebust}}"></script>
|
package/views/mbkbucket_info.hbs
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
<!DOCTYPE html>
|
|
9
9
|
<html lang="en">
|
|
10
10
|
|
|
11
|
-
{{>
|
|
11
|
+
{{> mbkutils_head pageTitle="System Information" ogUrl="/mbkbucket/info"}}
|
|
12
12
|
|
|
13
13
|
<body>
|
|
14
|
-
{{>
|
|
14
|
+
{{> mbkutils_header appName=APP_NAME customURL="/mbkbucket"}}
|
|
15
15
|
|
|
16
16
|
<section class="login-container">
|
|
17
|
-
{{>
|
|
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
|
-
{{>
|
|
133
|
+
{{> mbkutils_versionInfo}}
|
|
134
134
|
</body>
|
|
135
135
|
</html>
|