mbkbucket 3.8.3 → 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.
- package/.env.example +15 -14
- package/README.md +561 -353
- package/index.js +32 -30
- package/lib/public/bucketadmin.css +2042 -1708
- package/lib/public/bucketadmin.js +1622 -1286
- package/lib/public/mbkbucket-helper-demo.html +4 -24
- package/lib/public/mbkbucket-helper.css +28 -34
- package/lib/public/mbkbucket-helper.js +4 -3
- package/lib/src/config/validation.js +86 -73
- package/lib/src/controllers/api.controller.js +459 -410
- package/lib/src/controllers/dashboard.controller.js +69 -49
- package/lib/src/controllers/info.controller.js +34 -3
- package/lib/src/controllers/view.controller.js +233 -231
- package/lib/src/core/storage/errors.js +62 -0
- package/lib/src/core/storage/gdrive-auth.js +193 -0
- package/lib/src/core/storage/gdrive-provider.js +656 -0
- package/lib/src/core/storage/index.js +14 -0
- package/lib/src/core/storage/models.js +80 -0
- package/lib/src/core/storage/path-strategy.js +65 -0
- package/lib/src/core/storage/s3-provider.js +491 -0
- package/lib/src/core/storage/storage-manager.js +203 -0
- package/lib/src/core/storage/storage-provider.js +217 -0
- package/lib/src/middleware/bucket-resolver.js +37 -37
- package/lib/src/routes/api.routes.js +5 -1
- package/lib/src/routes/index.js +60 -60
- package/lib/src/routes/view.routes.js +28 -25
- package/lib/src/services/s3.service.js +42 -594
- package/lib/src/services/storage.service.js +213 -0
- package/lib/src/utils/errors.js +56 -46
- package/package.json +1 -1
- package/views/bucket.hbs +47 -71
- package/views/bucketadmin_move_modal.hbs +35 -0
- package/views/bucketadmin_preview_modal.hbs +108 -40
- package/views/bucketadmincontent.hbs +193 -144
- package/views/mbkbucket_info.hbs +134 -232
|
@@ -1,49 +1,69 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
}
|