mbkbucket 3.5.0 → 3.6.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.
Files changed (42) hide show
  1. package/.env.example +14 -14
  2. package/.mbkbucket.config.example.json +14 -14
  3. package/LICENSE +20 -20
  4. package/README.md +377 -350
  5. package/index.js +30 -30
  6. package/lib/cli.js +649 -649
  7. package/lib/public/bucketadmin.css +1420 -1420
  8. package/lib/public/bucketadmin.js +1229 -1229
  9. package/lib/public/mbkbucket-helper-demo.html +1184 -1184
  10. package/lib/public/mbkbucket-helper.css +1235 -1235
  11. package/lib/public/mbkbucket-helper.js +1557 -1557
  12. package/lib/src/app.js +109 -109
  13. package/lib/src/cli/auth-helper.js +150 -150
  14. package/lib/src/config/index.js +112 -112
  15. package/lib/src/config/validation.js +73 -73
  16. package/lib/src/controllers/api.controller.js +410 -410
  17. package/lib/src/controllers/dashboard.controller.js +48 -48
  18. package/lib/src/controllers/info.controller.js +34 -34
  19. package/lib/src/controllers/view.controller.js +233 -233
  20. package/lib/src/middleware/async-handler.js +23 -23
  21. package/lib/src/middleware/bucket-resolver.js +37 -37
  22. package/lib/src/middleware/error-handler.js +54 -54
  23. package/lib/src/middleware/rate-limiter.js +22 -22
  24. package/lib/src/middleware/security.js +27 -27
  25. package/lib/src/routes/api.routes.js +54 -40
  26. package/lib/src/routes/index.js +40 -21
  27. package/lib/src/routes/info.routes.js +9 -9
  28. package/lib/src/routes/view.routes.js +25 -20
  29. package/lib/src/services/s3.service.js +603 -603
  30. package/lib/src/utils/errors.js +54 -54
  31. package/lib/src/utils/helpers.js +251 -251
  32. package/lib/src/utils/logger.js +17 -17
  33. package/package.json +83 -83
  34. package/views/bucket.handlebars +196 -196
  35. package/views/bucketadmin_alerts.handlebars +15 -15
  36. package/views/bucketadmin_delete_modal.handlebars +36 -36
  37. package/views/bucketadmin_filelist_skeleton.handlebars +32 -32
  38. package/views/bucketadmin_newfolder_modal.handlebars +29 -29
  39. package/views/bucketadmin_preview_modal.handlebars +40 -40
  40. package/views/bucketadmin_upload_modal.handlebars +54 -54
  41. package/views/bucketadmincontent.handlebars +141 -141
  42. package/views/mbkbucket_info.handlebars +232 -232
@@ -1,48 +1,48 @@
1
- import fs from "fs";
2
- import path from "path";
3
- import { resolveBucketName, getAvailableBucketNames } from "../services/s3.service.js";
4
- import { renderPage, mbkautheVar } from "mbkauthe";
5
- import { commonHandlebarsHelpers } from "../utils/helpers.js";
6
-
7
- /**
8
- * Render the main bucket dashboard page.
9
- */
10
- export function renderBucketDashboard(req, res) {
11
- const allBuckets = getAvailableBucketNames();
12
- const configuredDefaultBucket = mbkautheVar?.bucket;
13
- const isPortal = mbkautheVar?.APP_NAME === "portal";
14
- const bucketfilePath = isPortal ? "bucketportal.handlebars" : "bucket.handlebars";
15
-
16
- const mainLayoutPath = path.resolve(process.cwd(), "views", "layouts", "main");
17
- const hasMainLayout = fs.existsSync(`${mainLayoutPath}.handlebars`) || fs.existsSync(mainLayoutPath);
18
- const portalLayout = (isPortal && hasMainLayout) ? mainLayoutPath : false;
19
-
20
- let selectedBucket;
21
- let error = req.query.error;
22
-
23
- try {
24
- selectedBucket = resolveBucketName(req.query.bucket);
25
- } catch (err) {
26
- selectedBucket = mbkautheVar?.bucket;
27
- error ||= err.message;
28
- }
29
-
30
- const bucketOptions = allBuckets.map(name => ({
31
- name,
32
- isSelected: name === selectedBucket,
33
- isDefault: name === configuredDefaultBucket
34
- }));
35
-
36
- renderPage(req, res, bucketfilePath, portalLayout ? true : false, {
37
- page: "Admin Bucket",
38
- helpers: commonHandlebarsHelpers,
39
- ...(portalLayout ? { layout: portalLayout } : { layout: false }),
40
- bucketvar: selectedBucket,
41
- selectedBucket,
42
- bucketOptions,
43
- APP_NAME: mbkautheVar?.APP_NAME,
44
- message: req.query.message,
45
- error,
46
- availableBuckets: allBuckets
47
- });
48
- }
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { resolveBucketName, getAvailableBucketNames } from "../services/s3.service.js";
4
+ import { renderPage, mbkautheVar } from "mbkauthe";
5
+ import { commonHandlebarsHelpers } from "../utils/helpers.js";
6
+
7
+ /**
8
+ * Render the main bucket dashboard page.
9
+ */
10
+ export function renderBucketDashboard(req, res) {
11
+ const allBuckets = getAvailableBucketNames();
12
+ const configuredDefaultBucket = mbkautheVar?.bucket;
13
+ const isPortal = mbkautheVar?.APP_NAME === "portal";
14
+ const bucketfilePath = isPortal ? "bucketportal.handlebars" : "bucket.handlebars";
15
+
16
+ const mainLayoutPath = path.resolve(process.cwd(), "views", "layouts", "main");
17
+ const hasMainLayout = fs.existsSync(`${mainLayoutPath}.handlebars`) || fs.existsSync(mainLayoutPath);
18
+ const portalLayout = (isPortal && hasMainLayout) ? mainLayoutPath : false;
19
+
20
+ let selectedBucket;
21
+ let error = req.query.error;
22
+
23
+ try {
24
+ selectedBucket = resolveBucketName(req.query.bucket);
25
+ } catch (err) {
26
+ selectedBucket = mbkautheVar?.bucket;
27
+ error ||= err.message;
28
+ }
29
+
30
+ const bucketOptions = allBuckets.map(name => ({
31
+ name,
32
+ isSelected: name === selectedBucket,
33
+ isDefault: name === configuredDefaultBucket
34
+ }));
35
+
36
+ renderPage(req, res, bucketfilePath, portalLayout ? true : false, {
37
+ page: "Admin Bucket",
38
+ helpers: commonHandlebarsHelpers,
39
+ ...(portalLayout ? { layout: portalLayout } : { layout: false }),
40
+ bucketvar: selectedBucket,
41
+ selectedBucket,
42
+ bucketOptions,
43
+ APP_NAME: mbkautheVar?.APP_NAME,
44
+ message: req.query.message,
45
+ error,
46
+ availableBuckets: allBuckets
47
+ });
48
+ }
@@ -1,34 +1,34 @@
1
- import { renderPage, mbkautheVar } from "mbkauthe";
2
- import { packageJson, getLatestVersion } from "../config/index.js";
3
-
4
- async function getInfoData() {
5
- let latestVersion = 'unknown';
6
- try {
7
- latestVersion = (await getLatestVersion()) || 'unknown';
8
- } catch (err) {
9
- console.error("[mbkbucket] Error fetching latest version:", err);
10
- }
11
- return {
12
- CurrentVersion: packageJson.version,
13
- latestVersion,
14
- APP_NAME: mbkautheVar?.APP_NAME
15
- };
16
- }
17
-
18
- /**
19
- * Render the mbkbucket info page (Handlebars).
20
- */
21
- export async function infoPage(req, res) {
22
- const data = await getInfoData();
23
- renderPage(req, res, "mbkbucket_info.handlebars", false, {
24
- page: "MBK Bucket Info",
25
- ...data
26
- });
27
- }
28
-
29
- /**
30
- * Return bucket info as JSON.
31
- */
32
- export async function infoJson(_req, res) {
33
- res.json(await getInfoData());
34
- }
1
+ import { renderPage, mbkautheVar } from "mbkauthe";
2
+ import { packageJson, getLatestVersion } from "../config/index.js";
3
+
4
+ async function getInfoData() {
5
+ let latestVersion = 'unknown';
6
+ try {
7
+ latestVersion = (await getLatestVersion()) || 'unknown';
8
+ } catch (err) {
9
+ console.error("[mbkbucket] Error fetching latest version:", err);
10
+ }
11
+ return {
12
+ CurrentVersion: packageJson.version,
13
+ latestVersion,
14
+ APP_NAME: mbkautheVar?.APP_NAME
15
+ };
16
+ }
17
+
18
+ /**
19
+ * Render the mbkbucket info page (Handlebars).
20
+ */
21
+ export async function infoPage(req, res) {
22
+ const data = await getInfoData();
23
+ renderPage(req, res, "mbkbucket_info.handlebars", false, {
24
+ page: "MBK Bucket Info",
25
+ ...data
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Return bucket info as JSON.
31
+ */
32
+ export async function infoJson(_req, res) {
33
+ res.json(await getInfoData());
34
+ }
@@ -1,233 +1,233 @@
1
- import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, generateSignedUrl, resolveBucketName } from "../services/s3.service.js";
2
- import { mbkbucketVar } from "../config/index.js";
3
- import { escapeHtml, getBaseName, getFileExt, getMimeType, normalizeKeyParam, destroyStream, VIEWABLE_TYPES, VIEWABLE_TYPES_SORTED, STATIC_ASSET_TYPES, VIDEO_TYPES, AUDIO_TYPES, buildCacheControl, parseRangeHeader, renderPlayerPage } from "../utils/helpers.js";
4
- import { createLogger } from "#logger";
5
-
6
- const debugView = createLogger('view');
7
-
8
- function attachStreamCleanup(req, res, stream, timeoutMs = 120000) {
9
- const cleanup = () => destroyStream(stream);
10
-
11
- stream.on('error', (err) => {
12
- console.error('Stream error:', err);
13
- cleanup();
14
- if (!res.headersSent) res.status(500).end('Stream error');
15
- });
16
-
17
- req.on('close', cleanup);
18
- req.on('aborted', cleanup);
19
- req.setTimeout(timeoutMs, () => {
20
- console.error('Request timeout for stream');
21
- cleanup();
22
- if (!res.headersSent) res.status(408).end('Request timeout');
23
- });
24
- res.on('finish', cleanup);
25
- res.on('close', cleanup);
26
- }
27
-
28
- async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName, publicCache = false } = {}) {
29
- const fileName = getBaseName(keyToUse);
30
- const fileExtension = getFileExt(fileName);
31
-
32
- debugView('Request for %s (type: %s, range: %s)', fileName, fileExtension, req.headers.range || 'none');
33
-
34
- if (!VIEWABLE_TYPES.has(fileExtension)) {
35
- return res.status(415).json({
36
- message: 'File type not supported for viewing',
37
- supportedTypes: VIEWABLE_TYPES_SORTED
38
- });
39
- }
40
-
41
- const ifNoneMatch = req.headers['if-none-match'];
42
- const ifModifiedSince = req.headers['if-modified-since'];
43
- const rangeHeader = req.headers.range;
44
-
45
- const isStaticAsset = STATIC_ASSET_TYPES.has(fileExtension);
46
- const isVideo = VIDEO_TYPES.has(fileExtension);
47
- const isAudio = AUDIO_TYPES.has(fileExtension);
48
- const isPdf = fileExtension === 'pdf';
49
- const supportsRanges = isVideo || isAudio || isPdf;
50
- const contentType = getMimeType(fileName);
51
-
52
- // Fast path for range requests (video / audio / PDF streaming)
53
- if (supportsRanges && rangeHeader) {
54
- debugView('Processing range request: %s for %s', rangeHeader, fileName);
55
- let meta;
56
- try {
57
- meta = await getFileMetadata(keyToUse, bucketName);
58
- } catch (err) {
59
- console.error('[mbkbucket] serveFileInline metadata error:', err);
60
- return res.status(500).json({ message: 'Failed to fetch file metadata', error: err.message });
61
- }
62
-
63
- if (!meta?.exists) {
64
- return res.status(404).json({ message: 'File not found', key: keyToUse });
65
- }
66
-
67
- const etag = `"${meta.LastModified?.getTime() || Date.now()}-${meta.ContentLength || 0}"`;
68
- if (ifNoneMatch === etag) return res.status(304).end();
69
-
70
- const total = Number(meta.ContentLength);
71
- if (total) {
72
- const range = parseRangeHeader(rangeHeader, total);
73
- if (!range) {
74
- res.status(416).setHeader('Content-Range', `bytes */${total}`);
75
- return res.end();
76
- }
77
-
78
- const { start, end, size: chunksize } = range;
79
- try {
80
- const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, bucketName });
81
- if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
82
- debugView('Range size mismatch for %s: expected=%s got=%s', keyToUse, chunksize, rangeResult.ContentLength);
83
- }
84
-
85
- debugView('Serving range bytes %s-%s/%s (%s bytes) for %s', start, end, total, chunksize, fileName);
86
-
87
- res.status(206);
88
- res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
89
- res.setHeader('Content-Length', chunksize);
90
- res.setHeader('Content-Type', contentType);
91
- res.setHeader('Cache-Control', buildCacheControl(contentType, { supportsRanges: true, publicCache }));
92
- res.setHeader('ETag', etag);
93
- res.setHeader('Accept-Ranges', 'bytes');
94
- if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
95
- res.setHeader('X-Content-Type-Options', 'nosniff');
96
- if (meta.LastModified) res.setHeader('Last-Modified', meta.LastModified.toUTCString());
97
-
98
- attachStreamCleanup(req, res, rangeResult.Body);
99
- return rangeResult.Body.pipe(res);
100
- } catch (rangeError) {
101
- console.error('Range request failed:', rangeError);
102
- }
103
- }
104
- }
105
-
106
- // Standard path: full-file download
107
- const downloadOptions = {
108
- ...(ifNoneMatch && { ifNoneMatch }),
109
- ...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) }),
110
- bucketName
111
- };
112
-
113
- if (isPdf) debugView('Serving full PDF (no range header) for %s', fileName);
114
-
115
- let result;
116
- try {
117
- result = await downloadFile(keyToUse, downloadOptions);
118
- } catch (err) {
119
- if (err.name === 'NoSuchKey' || err.name === 'NotFound') {
120
- return res.status(404).json({ message: 'File not found', key: keyToUse });
121
- }
122
- if (err.name === 'AccessDenied') {
123
- return res.status(403).json({ message: 'Access denied' });
124
- }
125
- console.error('[mbkbucket] serveFileInline download error:', err);
126
- return res.status(500).json({ message: 'Failed to fetch file', error: err.message });
127
- }
128
-
129
- if (result?.notModified) return res.status(304).end();
130
-
131
- const etag = `"${result.LastModified?.getTime() || Date.now()}-${result.ContentLength || 0}"`;
132
- if (ifNoneMatch === etag) return res.status(304).end();
133
-
134
- res.setHeader('Content-Type', contentType);
135
- res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
136
- res.setHeader('ETag', etag);
137
- res.setHeader('X-Content-Type-Options', 'nosniff');
138
- res.setHeader('X-Frame-Options', 'SAMEORIGIN');
139
- if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
140
- if (result.ContentLength) res.setHeader('Content-Length', result.ContentLength);
141
- if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
142
- if (fileExtension === 'html' || fileExtension === 'htm') {
143
- res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
144
- }
145
- if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
146
- res.setHeader('Cache-Control', buildCacheControl(contentType, { isStaticAsset, supportsRanges, publicCache }));
147
- res.status(200);
148
-
149
- const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
150
- attachStreamCleanup(req, res, result.Body, timeoutDuration);
151
- result.Body.pipe(res);
152
- }
153
-
154
- export async function viewFile(req, res) {
155
- try {
156
- const key = normalizeKeyParam(req.params.key);
157
- let keyToUse;
158
- try {
159
- keyToUse = ensureKeyHasAppPrefix(key);
160
- } catch (e) {
161
- return res.status(400).json({ message: e.message });
162
- }
163
-
164
- await serveFileInline(req, res, keyToUse, { noindex: true, bucketName: req.activeBucket, publicCache: false });
165
- } catch (error) {
166
- console.error("Error viewing file:", error);
167
- if (error.name === 'NoSuchKey' || error.name === 'NotFound') {
168
- res.status(404).json({ message: "File not found", key: req.params.key });
169
- } else if (error.name === 'AccessDenied') {
170
- res.status(403).json({ message: "Access denied" });
171
- } else {
172
- res.status(500).json({ message: "View failed", error: error.message });
173
- }
174
- }
175
- }
176
-
177
- export async function playerPage(req, res) {
178
- try {
179
- const key = normalizeKeyParam(req.params.key);
180
- let keyToUse;
181
- try {
182
- keyToUse = ensureKeyHasAppPrefix(key);
183
- } catch (e) {
184
- return res.status(400).send(e.message);
185
- }
186
-
187
- const html = renderPlayerPage(
188
- escapeHtml(getBaseName(keyToUse)),
189
- encodeURIComponent(keyToUse),
190
- `?bucket=${encodeURIComponent(req.activeBucket)}`
191
- );
192
-
193
- res.setHeader('Content-Type', 'text/html; charset=utf-8');
194
- res.setHeader('X-Content-Type-Options', 'nosniff');
195
- return res.status(200).send(html);
196
- } catch (err) {
197
- console.error('Error rendering player page:', err);
198
- return res.status(500).send('Failed to render player');
199
- }
200
- }
201
-
202
- export async function publicView(req, res) {
203
- try {
204
- const bucketName = resolveBucketName();
205
- const key = normalizeKeyParam(req.params.key);
206
-
207
- if (!key || key.endsWith('/')) {
208
- return res.status(403).json({ message: 'Directory indexing is not allowed' });
209
- }
210
-
211
- let keyToUse;
212
- try {
213
- keyToUse = ensureKeyHasAppPrefix(key);
214
- } catch (e) {
215
- return res.status(400).json({ message: e.message });
216
- }
217
-
218
- if (!mbkbucketVar?.p_view_inline) {
219
- try {
220
- const signed = await generateSignedUrl(keyToUse, 'getObject', 3600, bucketName);
221
- return res.redirect(302, signed.url);
222
- } catch (err) {
223
- console.error('[mbkbucket] p_view signed URL error:', err);
224
- return res.status(500).json({ message: 'Failed to generate access URL' });
225
- }
226
- }
227
-
228
- await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: true });
229
- } catch (error) {
230
- console.error('[mbkbucket] p_view error:', error);
231
- res.status(500).json({ message: 'Internal server error', error: error.message });
232
- }
233
- }
1
+ import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, generateSignedUrl, resolveBucketName } from "../services/s3.service.js";
2
+ import { mbkbucketVar } from "../config/index.js";
3
+ import { escapeHtml, getBaseName, getFileExt, getMimeType, normalizeKeyParam, destroyStream, VIEWABLE_TYPES, VIEWABLE_TYPES_SORTED, STATIC_ASSET_TYPES, VIDEO_TYPES, AUDIO_TYPES, buildCacheControl, parseRangeHeader, renderPlayerPage } from "../utils/helpers.js";
4
+ import { createLogger } from "#logger";
5
+
6
+ const debugView = createLogger('view');
7
+
8
+ function attachStreamCleanup(req, res, stream, timeoutMs = 120000) {
9
+ const cleanup = () => destroyStream(stream);
10
+
11
+ stream.on('error', (err) => {
12
+ console.error('Stream error:', err);
13
+ cleanup();
14
+ if (!res.headersSent) res.status(500).end('Stream error');
15
+ });
16
+
17
+ req.on('close', cleanup);
18
+ req.on('aborted', cleanup);
19
+ req.setTimeout(timeoutMs, () => {
20
+ console.error('Request timeout for stream');
21
+ cleanup();
22
+ if (!res.headersSent) res.status(408).end('Request timeout');
23
+ });
24
+ res.on('finish', cleanup);
25
+ res.on('close', cleanup);
26
+ }
27
+
28
+ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName, publicCache = false } = {}) {
29
+ const fileName = getBaseName(keyToUse);
30
+ const fileExtension = getFileExt(fileName);
31
+
32
+ debugView('Request for %s (type: %s, range: %s)', fileName, fileExtension, req.headers.range || 'none');
33
+
34
+ if (!VIEWABLE_TYPES.has(fileExtension)) {
35
+ return res.status(415).json({
36
+ message: 'File type not supported for viewing',
37
+ supportedTypes: VIEWABLE_TYPES_SORTED
38
+ });
39
+ }
40
+
41
+ const ifNoneMatch = req.headers['if-none-match'];
42
+ const ifModifiedSince = req.headers['if-modified-since'];
43
+ const rangeHeader = req.headers.range;
44
+
45
+ const isStaticAsset = STATIC_ASSET_TYPES.has(fileExtension);
46
+ const isVideo = VIDEO_TYPES.has(fileExtension);
47
+ const isAudio = AUDIO_TYPES.has(fileExtension);
48
+ const isPdf = fileExtension === 'pdf';
49
+ const supportsRanges = isVideo || isAudio || isPdf;
50
+ const contentType = getMimeType(fileName);
51
+
52
+ // Fast path for range requests (video / audio / PDF streaming)
53
+ if (supportsRanges && rangeHeader) {
54
+ debugView('Processing range request: %s for %s', rangeHeader, fileName);
55
+ let meta;
56
+ try {
57
+ meta = await getFileMetadata(keyToUse, bucketName);
58
+ } catch (err) {
59
+ console.error('[mbkbucket] serveFileInline metadata error:', err);
60
+ return res.status(500).json({ message: 'Failed to fetch file metadata', error: err.message });
61
+ }
62
+
63
+ if (!meta?.exists) {
64
+ return res.status(404).json({ message: 'File not found', key: keyToUse });
65
+ }
66
+
67
+ const etag = `"${meta.LastModified?.getTime() || Date.now()}-${meta.ContentLength || 0}"`;
68
+ if (ifNoneMatch === etag) return res.status(304).end();
69
+
70
+ const total = Number(meta.ContentLength);
71
+ if (total) {
72
+ const range = parseRangeHeader(rangeHeader, total);
73
+ if (!range) {
74
+ res.status(416).setHeader('Content-Range', `bytes */${total}`);
75
+ return res.end();
76
+ }
77
+
78
+ const { start, end, size: chunksize } = range;
79
+ try {
80
+ const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, bucketName });
81
+ if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
82
+ debugView('Range size mismatch for %s: expected=%s got=%s', keyToUse, chunksize, rangeResult.ContentLength);
83
+ }
84
+
85
+ debugView('Serving range bytes %s-%s/%s (%s bytes) for %s', start, end, total, chunksize, fileName);
86
+
87
+ res.status(206);
88
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
89
+ res.setHeader('Content-Length', chunksize);
90
+ res.setHeader('Content-Type', contentType);
91
+ res.setHeader('Cache-Control', buildCacheControl(contentType, { supportsRanges: true, publicCache }));
92
+ res.setHeader('ETag', etag);
93
+ res.setHeader('Accept-Ranges', 'bytes');
94
+ if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
95
+ res.setHeader('X-Content-Type-Options', 'nosniff');
96
+ if (meta.LastModified) res.setHeader('Last-Modified', meta.LastModified.toUTCString());
97
+
98
+ attachStreamCleanup(req, res, rangeResult.Body);
99
+ return rangeResult.Body.pipe(res);
100
+ } catch (rangeError) {
101
+ console.error('Range request failed:', rangeError);
102
+ }
103
+ }
104
+ }
105
+
106
+ // Standard path: full-file download
107
+ const downloadOptions = {
108
+ ...(ifNoneMatch && { ifNoneMatch }),
109
+ ...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) }),
110
+ bucketName
111
+ };
112
+
113
+ if (isPdf) debugView('Serving full PDF (no range header) for %s', fileName);
114
+
115
+ let result;
116
+ try {
117
+ result = await downloadFile(keyToUse, downloadOptions);
118
+ } catch (err) {
119
+ if (err.name === 'NoSuchKey' || err.name === 'NotFound') {
120
+ return res.status(404).json({ message: 'File not found', key: keyToUse });
121
+ }
122
+ if (err.name === 'AccessDenied') {
123
+ return res.status(403).json({ message: 'Access denied' });
124
+ }
125
+ console.error('[mbkbucket] serveFileInline download error:', err);
126
+ return res.status(500).json({ message: 'Failed to fetch file', error: err.message });
127
+ }
128
+
129
+ if (result?.notModified) return res.status(304).end();
130
+
131
+ const etag = `"${result.LastModified?.getTime() || Date.now()}-${result.ContentLength || 0}"`;
132
+ if (ifNoneMatch === etag) return res.status(304).end();
133
+
134
+ res.setHeader('Content-Type', contentType);
135
+ res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
136
+ res.setHeader('ETag', etag);
137
+ res.setHeader('X-Content-Type-Options', 'nosniff');
138
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
139
+ if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
140
+ if (result.ContentLength) res.setHeader('Content-Length', result.ContentLength);
141
+ if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
142
+ if (fileExtension === 'html' || fileExtension === 'htm') {
143
+ res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
144
+ }
145
+ if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
146
+ res.setHeader('Cache-Control', buildCacheControl(contentType, { isStaticAsset, supportsRanges, publicCache }));
147
+ res.status(200);
148
+
149
+ const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
150
+ attachStreamCleanup(req, res, result.Body, timeoutDuration);
151
+ result.Body.pipe(res);
152
+ }
153
+
154
+ export async function viewFile(req, res) {
155
+ try {
156
+ const key = normalizeKeyParam(req.params.key);
157
+ let keyToUse;
158
+ try {
159
+ keyToUse = ensureKeyHasAppPrefix(key);
160
+ } catch (e) {
161
+ return res.status(400).json({ message: e.message });
162
+ }
163
+
164
+ await serveFileInline(req, res, keyToUse, { noindex: true, bucketName: req.activeBucket, publicCache: false });
165
+ } catch (error) {
166
+ console.error("Error viewing file:", error);
167
+ if (error.name === 'NoSuchKey' || error.name === 'NotFound') {
168
+ res.status(404).json({ message: "File not found", key: req.params.key });
169
+ } else if (error.name === 'AccessDenied') {
170
+ res.status(403).json({ message: "Access denied" });
171
+ } else {
172
+ res.status(500).json({ message: "View failed", error: error.message });
173
+ }
174
+ }
175
+ }
176
+
177
+ export async function playerPage(req, res) {
178
+ try {
179
+ const key = normalizeKeyParam(req.params.key);
180
+ let keyToUse;
181
+ try {
182
+ keyToUse = ensureKeyHasAppPrefix(key);
183
+ } catch (e) {
184
+ return res.status(400).send(e.message);
185
+ }
186
+
187
+ const html = renderPlayerPage(
188
+ escapeHtml(getBaseName(keyToUse)),
189
+ encodeURIComponent(keyToUse),
190
+ `?bucket=${encodeURIComponent(req.activeBucket)}`
191
+ );
192
+
193
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
194
+ res.setHeader('X-Content-Type-Options', 'nosniff');
195
+ return res.status(200).send(html);
196
+ } catch (err) {
197
+ console.error('Error rendering player page:', err);
198
+ return res.status(500).send('Failed to render player');
199
+ }
200
+ }
201
+
202
+ export async function publicView(req, res) {
203
+ try {
204
+ const bucketName = resolveBucketName();
205
+ const key = normalizeKeyParam(req.params.key);
206
+
207
+ if (!key || key.endsWith('/')) {
208
+ return res.status(403).json({ message: 'Directory indexing is not allowed' });
209
+ }
210
+
211
+ let keyToUse;
212
+ try {
213
+ keyToUse = ensureKeyHasAppPrefix(key);
214
+ } catch (e) {
215
+ return res.status(400).json({ message: e.message });
216
+ }
217
+
218
+ if (!mbkbucketVar?.p_view_inline) {
219
+ try {
220
+ const signed = await generateSignedUrl(keyToUse, 'getObject', 3600, bucketName);
221
+ return res.redirect(302, signed.url);
222
+ } catch (err) {
223
+ console.error('[mbkbucket] p_view signed URL error:', err);
224
+ return res.status(500).json({ message: 'Failed to generate access URL' });
225
+ }
226
+ }
227
+
228
+ await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: true });
229
+ } catch (error) {
230
+ console.error('[mbkbucket] p_view error:', error);
231
+ res.status(500).json({ message: 'Internal server error', error: error.message });
232
+ }
233
+ }