mbkbucket 3.8.2 → 4.0.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 (38) hide show
  1. package/.env.example +15 -14
  2. package/README.md +561 -377
  3. package/index.js +32 -30
  4. package/lib/cli.js +154 -565
  5. package/lib/public/bucketadmin.css +2042 -1708
  6. package/lib/public/bucketadmin.js +1622 -1286
  7. package/lib/public/mbkbucket-helper-demo.html +4 -24
  8. package/lib/public/mbkbucket-helper.css +28 -34
  9. package/lib/public/mbkbucket-helper.js +4 -3
  10. package/lib/src/config/validation.js +86 -73
  11. package/lib/src/controllers/api.controller.js +459 -410
  12. package/lib/src/controllers/dashboard.controller.js +69 -49
  13. package/lib/src/controllers/info.controller.js +34 -3
  14. package/lib/src/controllers/view.controller.js +233 -231
  15. package/lib/src/core/storage/errors.js +62 -0
  16. package/lib/src/core/storage/gdrive-auth.js +193 -0
  17. package/lib/src/core/storage/gdrive-provider.js +656 -0
  18. package/lib/src/core/storage/index.js +14 -0
  19. package/lib/src/core/storage/models.js +80 -0
  20. package/lib/src/core/storage/path-strategy.js +65 -0
  21. package/lib/src/core/storage/s3-provider.js +491 -0
  22. package/lib/src/core/storage/storage-manager.js +203 -0
  23. package/lib/src/core/storage/storage-provider.js +217 -0
  24. package/lib/src/middleware/bucket-resolver.js +37 -37
  25. package/lib/src/routes/api.routes.js +5 -1
  26. package/lib/src/routes/index.js +60 -60
  27. package/lib/src/routes/view.routes.js +28 -25
  28. package/lib/src/services/s3.service.js +42 -594
  29. package/lib/src/services/storage.service.js +213 -0
  30. package/lib/src/utils/errors.js +56 -46
  31. package/package.json +11 -12
  32. package/views/bucket.hbs +47 -71
  33. package/views/bucketadmin_move_modal.hbs +35 -0
  34. package/views/bucketadmin_preview_modal.hbs +108 -40
  35. package/views/bucketadmincontent.hbs +193 -144
  36. package/views/mbkbucket_info.hbs +134 -232
  37. package/.mbkbucket.config.example.json +0 -14
  38. package/lib/src/cli/auth-helper.js +0 -141
@@ -1,49 +1,69 @@
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.hbs" : "bucket.hbs";
15
-
16
- const mainLayoutPath = path.resolve(process.cwd(), "views", "layouts", "main");
17
- const hasMainLayout = fs.existsSync(`${mainLayoutPath}.hbs`) || fs.existsSync(mainLayoutPath);
18
- const portalLayout = (isPortal && hasMainLayout) && mainLayoutPath;
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, Boolean(portalLayout), {
37
- page: "Admin Bucket",
38
- helpers: commonHandlebarsHelpers,
39
- layout: portalLayout || 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
- }
49
-
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { resolveStorageName, getAvailableStorageNames, getStorageProvider, getStorageConfig } from "../services/storage.service.js";
4
+ import { storageManager } from "../core/storage/storage-manager.js";
5
+ import { renderPage, mbkautheVar } from "mbkauthe";
6
+ import { commonHandlebarsHelpers } from "../utils/helpers.js";
7
+ import { mbkbucketVar } from "../config/index.js";
8
+
9
+ /**
10
+ * Render the main storage/bucket dashboard page.
11
+ */
12
+ export function renderBucketDashboard(req, res) {
13
+ const allBuckets = getAvailableStorageNames();
14
+ const configuredDefaultBucket = mbkautheVar?.bucket;
15
+ const isPortal = mbkautheVar?.APP_NAME === "portal";
16
+ const bucketfilePath = isPortal ? "bucketportal.hbs" : "bucket.hbs";
17
+
18
+ const mainLayoutPath = path.resolve(process.cwd(), "views", "layouts", "main");
19
+ const hasMainLayout = fs.existsSync(`${mainLayoutPath}.hbs`) || fs.existsSync(mainLayoutPath);
20
+ const portalLayout = (isPortal && hasMainLayout) && mainLayoutPath;
21
+
22
+ let selectedBucket;
23
+ let error = req.query.error;
24
+ let providerType = 's3';
25
+ let providerCapabilities = {};
26
+
27
+ try {
28
+ selectedBucket = resolveStorageName(req.query.bucket);
29
+ const provider = getStorageProvider(selectedBucket);
30
+ providerType = provider.type;
31
+ providerCapabilities = provider.capabilities;
32
+ } catch (err) {
33
+ selectedBucket = mbkautheVar?.bucket;
34
+ error ||= err.message;
35
+ }
36
+
37
+ const bucketOptions = allBuckets.map(name => {
38
+ let type = 's3';
39
+ try {
40
+ const cfg = getStorageConfig(name);
41
+ type = storageManager.inferProviderType(cfg);
42
+ } catch {}
43
+ return {
44
+ name,
45
+ type,
46
+ isSelected: name === selectedBucket,
47
+ isDefault: name === configuredDefaultBucket
48
+ };
49
+ });
50
+
51
+ const publicViewEnabled = Boolean(mbkbucketVar?.publiView_enabled);
52
+
53
+ renderPage(req, res, bucketfilePath, Boolean(portalLayout), {
54
+ page: "Admin Bucket",
55
+ helpers: commonHandlebarsHelpers,
56
+ layout: portalLayout || false,
57
+ bucketvar: selectedBucket,
58
+ selectedBucket,
59
+ bucketOptions,
60
+ providerType,
61
+ providerCapabilities,
62
+ APP_NAME: mbkautheVar?.APP_NAME,
63
+ publicViewEnabled,
64
+ publiViewEnabled: publicViewEnabled,
65
+ message: req.query.message,
66
+ error,
67
+ availableBuckets: allBuckets
68
+ });
69
+ }
@@ -1,5 +1,7 @@
1
1
  import { renderPage, mbkautheVar } from "mbkauthe";
2
- import { packageJson, getLatestVersion } from "../config/index.js";
2
+ import { packageJson, getLatestVersion, appVersion, mbkbucketVar } from "../config/index.js";
3
+ import { getAvailableBucketNames, getStorageProvider } from "../services/storage.service.js";
4
+ import { storageManager } from "../core/storage/storage-manager.js";
3
5
 
4
6
  async function getInfoData() {
5
7
  let latestVersion = 'unknown';
@@ -8,10 +10,38 @@ async function getInfoData() {
8
10
  } catch (err) {
9
11
  console.error("[mbkbucket] Error fetching latest version:", err);
10
12
  }
13
+
14
+ const buckets = getAvailableBucketNames();
15
+ const defaultBucket = storageManager.getDefaultConnectionName() || (buckets[0] ?? '');
16
+ let providerType = 'S3';
17
+ if (defaultBucket) {
18
+ try {
19
+ const provider = getStorageProvider(defaultBucket);
20
+ providerType = (provider?.type || 's3').toUpperCase();
21
+ } catch {}
22
+ }
23
+
24
+ // Safe subset only - strictly prevent leaking passwords, DB connection strings, OAuth secrets, or storage credentials
25
+ const safeMbkautheVar = {
26
+ APP_NAME: mbkautheVar?.APP_NAME || mbkautheVar?.app_name || "mbkbucket",
27
+ };
28
+
29
+ const safeMbkbucketVar = {
30
+ publiView_enabled: Boolean(mbkbucketVar?.publiView_enabled),
31
+ p_view_inline: Boolean(mbkbucketVar?.p_view_inline)
32
+ };
33
+
11
34
  return {
12
35
  CurrentVersion: packageJson.version,
36
+ version: packageJson.version,
13
37
  latestVersion,
14
- APP_NAME: mbkautheVar?.APP_NAME
38
+ APP_NAME: safeMbkautheVar.APP_NAME,
39
+ APP_VERSION: appVersion,
40
+ mbkautheVar: safeMbkautheVar,
41
+ mbkbucketVar: safeMbkbucketVar,
42
+ defaultBucket,
43
+ configuredBucketsCount: buckets.length,
44
+ providerType
15
45
  };
16
46
  }
17
47
 
@@ -20,7 +50,8 @@ async function getInfoData() {
20
50
  */
21
51
  export async function infoPage(req, res) {
22
52
  renderPage(req, res, "mbkbucket_info.hbs", false, {
23
- page: "MBK Bucket Info",
53
+ pageTitle: "MBKBucket Information",
54
+ ogUrl: "/mbkbucket/info",
24
55
  ...(await getInfoData())
25
56
  });
26
57
  }
@@ -1,231 +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) return res.status(404).json({ message: 'File not found', key: keyToUse });
64
-
65
- const etag = `"${meta.LastModified?.getTime() || Date.now()}-${meta.ContentLength || 0}"`;
66
- if (ifNoneMatch === etag) return res.status(304).end();
67
-
68
- const total = Number(meta.ContentLength);
69
- if (total) {
70
- const range = parseRangeHeader(rangeHeader, total);
71
- if (!range) {
72
- res.status(416).setHeader('Content-Range', `bytes */${total}`);
73
- return res.end();
74
- }
75
-
76
- const { start, end, size: chunksize } = range;
77
- try {
78
- const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, bucketName });
79
- if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
80
- debugView('Range size mismatch for %s: expected=%s got=%s', keyToUse, chunksize, rangeResult.ContentLength);
81
- }
82
-
83
- debugView('Serving range bytes %s-%s/%s (%s bytes) for %s', start, end, total, chunksize, fileName);
84
-
85
- res.status(206);
86
- res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
87
- res.setHeader('Content-Length', chunksize);
88
- res.setHeader('Content-Type', contentType);
89
- res.setHeader('Cache-Control', buildCacheControl(contentType, { supportsRanges: true, publicCache }));
90
- res.setHeader('ETag', etag);
91
- res.setHeader('Accept-Ranges', 'bytes');
92
- if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
93
- res.setHeader('X-Content-Type-Options', 'nosniff');
94
- if (meta.LastModified) res.setHeader('Last-Modified', meta.LastModified.toUTCString());
95
-
96
- attachStreamCleanup(req, res, rangeResult.Body);
97
- return rangeResult.Body.pipe(res);
98
- } catch (rangeError) {
99
- console.error('Range request failed:', rangeError);
100
- }
101
- }
102
- }
103
-
104
- // Standard path: full-file download
105
- const downloadOptions = {
106
- ...(ifNoneMatch && { ifNoneMatch }),
107
- ...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) }),
108
- bucketName
109
- };
110
-
111
- if (isPdf) debugView('Serving full PDF (no range header) for %s', fileName);
112
-
113
- let result;
114
- try {
115
- result = await downloadFile(keyToUse, downloadOptions);
116
- } catch (err) {
117
- if (err.name === 'NoSuchKey' || err.name === 'NotFound') {
118
- return res.status(404).json({ message: 'File not found', key: keyToUse });
119
- }
120
- if (err.name === 'AccessDenied') {
121
- return res.status(403).json({ message: 'Access denied' });
122
- }
123
- console.error('[mbkbucket] serveFileInline download error:', err);
124
- return res.status(500).json({ message: 'Failed to fetch file', error: err.message });
125
- }
126
-
127
- if (result?.notModified) return res.status(304).end();
128
-
129
- const etag = `"${result.LastModified?.getTime() || Date.now()}-${result.ContentLength || 0}"`;
130
- if (ifNoneMatch === etag) return res.status(304).end();
131
-
132
- res.setHeader('Content-Type', contentType);
133
- res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
134
- res.setHeader('ETag', etag);
135
- res.setHeader('X-Content-Type-Options', 'nosniff');
136
- res.setHeader('X-Frame-Options', 'SAMEORIGIN');
137
- if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
138
- if (result.ContentLength) res.setHeader('Content-Length', result.ContentLength);
139
- if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
140
- if (fileExtension === 'html' || fileExtension === 'htm') {
141
- res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
142
- }
143
- if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
144
- res.setHeader('Cache-Control', buildCacheControl(contentType, { isStaticAsset, supportsRanges, publicCache }));
145
- res.status(200);
146
-
147
- const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
148
- attachStreamCleanup(req, res, result.Body, timeoutDuration);
149
- result.Body.pipe(res);
150
- }
151
-
152
- export async function viewFile(req, res) {
153
- try {
154
- const key = normalizeKeyParam(req.params.key);
155
- let keyToUse;
156
- try {
157
- keyToUse = ensureKeyHasAppPrefix(key);
158
- } catch (e) {
159
- return res.status(400).json({ message: e.message });
160
- }
161
-
162
- await serveFileInline(req, res, keyToUse, { noindex: true, bucketName: req.activeBucket, publicCache: false });
163
- } catch (error) {
164
- console.error("Error viewing file:", error);
165
- if (error.name === 'NoSuchKey' || error.name === 'NotFound') {
166
- res.status(404).json({ message: "File not found", key: req.params.key });
167
- } else if (error.name === 'AccessDenied') {
168
- res.status(403).json({ message: "Access denied" });
169
- } else {
170
- res.status(500).json({ message: "View failed", error: error.message });
171
- }
172
- }
173
- }
174
-
175
- export async function playerPage(req, res) {
176
- try {
177
- const key = normalizeKeyParam(req.params.key);
178
- let keyToUse;
179
- try {
180
- keyToUse = ensureKeyHasAppPrefix(key);
181
- } catch (e) {
182
- return res.status(400).send(e.message);
183
- }
184
-
185
- const html = renderPlayerPage(
186
- escapeHtml(getBaseName(keyToUse)),
187
- encodeURIComponent(keyToUse),
188
- `?bucket=${encodeURIComponent(req.activeBucket)}`
189
- );
190
-
191
- res.setHeader('Content-Type', 'text/html; charset=utf-8');
192
- res.setHeader('X-Content-Type-Options', 'nosniff');
193
- return res.status(200).send(html);
194
- } catch (err) {
195
- console.error('Error rendering player page:', err);
196
- return res.status(500).send('Failed to render player');
197
- }
198
- }
199
-
200
- export async function publicView(req, res) {
201
- try {
202
- const bucketName = resolveBucketName();
203
- const key = normalizeKeyParam(req.params.key);
204
-
205
- if (!key || key.endsWith('/')) {
206
- return res.status(403).json({ message: 'Directory indexing is not allowed' });
207
- }
208
-
209
- let keyToUse;
210
- try {
211
- keyToUse = ensureKeyHasAppPrefix(key);
212
- } catch (e) {
213
- return res.status(400).json({ message: e.message });
214
- }
215
-
216
- if (!mbkbucketVar?.p_view_inline) {
217
- try {
218
- const signed = await generateSignedUrl(keyToUse, 'getObject', 3600, bucketName);
219
- return res.redirect(302, signed.url);
220
- } catch (err) {
221
- console.error('[mbkbucket] p_view signed URL error:', err);
222
- return res.status(500).json({ message: 'Failed to generate access URL' });
223
- }
224
- }
225
-
226
- await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: true });
227
- } catch (error) {
228
- console.error('[mbkbucket] p_view error:', error);
229
- res.status(500).json({ message: 'Internal server error', error: error.message });
230
- }
231
- }
1
+ import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, generateSignedUrl, resolveStorageName, getStorageProvider } from "../services/storage.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) return res.status(404).json({ message: 'File not found', key: keyToUse });
64
+
65
+ const etag = `"${new Date(meta.LastModified || Date.now()).getTime()}-${meta.ContentLength || 0}"`;
66
+ if (ifNoneMatch === etag) return res.status(304).end();
67
+
68
+ const total = Number(meta.ContentLength);
69
+ if (total) {
70
+ const range = parseRangeHeader(rangeHeader, total);
71
+ if (!range) {
72
+ res.status(416).setHeader('Content-Range', `bytes */${total}`);
73
+ return res.end();
74
+ }
75
+
76
+ const { start, end, size: chunksize } = range;
77
+ try {
78
+ const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, bucketName });
79
+ if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
80
+ debugView('Range size mismatch for %s: expected=%s got=%s', keyToUse, chunksize, rangeResult.ContentLength);
81
+ }
82
+
83
+ debugView('Serving range bytes %s-%s/%s (%s bytes) for %s', start, end, total, chunksize, fileName);
84
+
85
+ res.status(206);
86
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
87
+ res.setHeader('Content-Length', chunksize);
88
+ res.setHeader('Content-Type', contentType);
89
+ res.setHeader('Cache-Control', buildCacheControl(contentType, { supportsRanges: true, publicCache }));
90
+ res.setHeader('ETag', etag);
91
+ res.setHeader('Accept-Ranges', 'bytes');
92
+ if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
93
+ res.setHeader('X-Content-Type-Options', 'nosniff');
94
+ if (meta.LastModified) res.setHeader('Last-Modified', new Date(meta.LastModified).toUTCString());
95
+
96
+ attachStreamCleanup(req, res, rangeResult.Body);
97
+ return rangeResult.Body.pipe(res);
98
+ } catch (rangeError) {
99
+ console.error('Range request failed:', rangeError);
100
+ }
101
+ }
102
+ }
103
+
104
+ // Standard path: full-file download
105
+ const downloadOptions = {
106
+ ...(ifNoneMatch && { ifNoneMatch }),
107
+ ...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) }),
108
+ bucketName
109
+ };
110
+
111
+ if (isPdf) debugView('Serving full PDF (no range header) for %s', fileName);
112
+
113
+ let result;
114
+ try {
115
+ result = await downloadFile(keyToUse, downloadOptions);
116
+ } catch (err) {
117
+ if (err.name === 'NoSuchKey' || err.name === 'NotFound' || err.message?.includes('File not found')) {
118
+ return res.status(404).json({ message: 'File not found', key: keyToUse });
119
+ }
120
+ if (err.name === 'AccessDenied' || err.message?.includes('Access denied')) {
121
+ return res.status(403).json({ message: 'Access denied' });
122
+ }
123
+ console.error('[mbkbucket] serveFileInline download error:', err);
124
+ return res.status(500).json({ message: 'Failed to fetch file', error: err.message });
125
+ }
126
+
127
+ if (result?.notModified) return res.status(304).end();
128
+
129
+ const etag = `"${new Date(result.LastModified || Date.now()).getTime()}-${result.ContentLength || 0}"`;
130
+ if (ifNoneMatch === etag) return res.status(304).end();
131
+
132
+ res.setHeader('Content-Type', contentType);
133
+ res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
134
+ res.setHeader('ETag', etag);
135
+ res.setHeader('X-Content-Type-Options', 'nosniff');
136
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
137
+ if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
138
+ if (result.ContentLength) res.setHeader('Content-Length', result.ContentLength);
139
+ if (result.LastModified) res.setHeader('Last-Modified', new Date(result.LastModified).toUTCString());
140
+ if (fileExtension === 'html' || fileExtension === 'htm') {
141
+ res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
142
+ }
143
+ if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
144
+ res.setHeader('Cache-Control', buildCacheControl(contentType, { isStaticAsset, supportsRanges, publicCache }));
145
+ res.status(200);
146
+
147
+ const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
148
+ attachStreamCleanup(req, res, result.Body, timeoutDuration);
149
+ result.Body.pipe(res);
150
+ }
151
+
152
+ export async function viewFile(req, res) {
153
+ try {
154
+ const key = normalizeKeyParam(req.params.key);
155
+ let keyToUse;
156
+ try {
157
+ keyToUse = ensureKeyHasAppPrefix(key);
158
+ } catch (e) {
159
+ return res.status(400).json({ message: e.message });
160
+ }
161
+
162
+ await serveFileInline(req, res, keyToUse, { noindex: true, bucketName: req.activeBucket, publicCache: false });
163
+ } catch (error) {
164
+ console.error("Error viewing file:", error);
165
+ if (error.name === 'NoSuchKey' || error.name === 'NotFound' || error.message?.includes('File not found')) {
166
+ res.status(404).json({ message: "File not found", key: req.params.key });
167
+ } else if (error.name === 'AccessDenied' || error.message?.includes('Access denied')) {
168
+ res.status(403).json({ message: "Access denied" });
169
+ } else {
170
+ res.status(500).json({ message: "View failed", error: error.message });
171
+ }
172
+ }
173
+ }
174
+
175
+ export async function playerPage(req, res) {
176
+ try {
177
+ const key = normalizeKeyParam(req.params.key);
178
+ let keyToUse;
179
+ try {
180
+ keyToUse = ensureKeyHasAppPrefix(key);
181
+ } catch (e) {
182
+ return res.status(400).send(e.message);
183
+ }
184
+
185
+ const html = renderPlayerPage(
186
+ escapeHtml(getBaseName(keyToUse)),
187
+ encodeURIComponent(keyToUse),
188
+ `?bucket=${encodeURIComponent(req.activeBucket)}`
189
+ );
190
+
191
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
192
+ res.setHeader('X-Content-Type-Options', 'nosniff');
193
+ return res.status(200).send(html);
194
+ } catch (err) {
195
+ console.error('Error rendering player page:', err);
196
+ return res.status(500).send('Failed to render player');
197
+ }
198
+ }
199
+
200
+ export async function publicView(req, res) {
201
+ try {
202
+ const bucketName = req.query?.bucket ? resolveStorageName(req.query.bucket) : resolveStorageName();
203
+ const key = normalizeKeyParam(req.params.key);
204
+
205
+ if (!key || key.endsWith('/')) {
206
+ return res.status(403).json({ message: 'Directory indexing is not allowed' });
207
+ }
208
+
209
+ let keyToUse;
210
+ try {
211
+ keyToUse = ensureKeyHasAppPrefix(key);
212
+ } catch (e) {
213
+ return res.status(400).json({ message: e.message });
214
+ }
215
+
216
+ if (!mbkbucketVar?.p_view_inline) {
217
+ try {
218
+ const provider = getStorageProvider(bucketName);
219
+ if (provider.capabilities.presignedUrls) {
220
+ const signed = await generateSignedUrl(keyToUse, 'getObject', 3600, bucketName);
221
+ return res.redirect(302, signed.url);
222
+ }
223
+ } catch (err) {
224
+ debugView('Signed URL redirection failed, falling back to inline serve: %s', err.message);
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
+ }