mbkbucket 2.1.0 → 3.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 +4 -2
- package/.github/PACKAGE.md +6 -0
- package/.mbkbucket.config.example.json +14 -0
- package/LICENSE +161 -335
- package/README.md +262 -81
- package/cli.js +977 -0
- package/index.js +18 -140
- package/lib/index.js +7 -7
- package/package.json +18 -6
- package/public/bucketadmin.js +41 -6
- package/src/app.js +110 -0
- package/src/cli/auth-helper.js +223 -0
- package/{lib → src}/config/index.js +26 -19
- package/{lib/routes/api.js → src/controllers/api.controller.js} +125 -221
- package/{lib/routes/index.js → src/controllers/dashboard.controller.js} +8 -36
- package/src/controllers/info.controller.js +37 -0
- package/src/controllers/view.controller.js +276 -0
- package/src/middleware/async-handler.js +38 -0
- package/src/middleware/bucket-resolver.js +43 -0
- package/src/middleware/error-handler.js +34 -0
- package/src/middleware/rate-limiter.js +30 -0
- package/src/middleware/security.js +43 -0
- package/src/routes/api.routes.js +66 -0
- package/src/routes/index.js +22 -0
- package/src/routes/info.routes.js +10 -0
- package/src/routes/view.routes.js +25 -0
- package/{lib/s3.js → src/services/s3.service.js} +116 -190
- package/src/utils/errors.js +76 -0
- package/src/utils/helpers.js +173 -0
- package/{lib/debug.js → src/utils/logger.js} +5 -0
- package/test/config-validation.test.js +1 -1
- package/views/bucket.handlebars +0 -1
- package/lib/routes/info.js +0 -28
- package/lib/routes/view.js +0 -531
- /package/{lib → src}/config/validation.js +0 -0
|
@@ -1,25 +1,20 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import viewRoutes from "./view.js";
|
|
4
|
-
import apiRoutes from "./api.js";
|
|
5
|
-
import { validateSessionAndRole, renderPage, mbkautheVar } from "mbkauthe";
|
|
6
|
-
import { resolveBucketName, getAvailableBucketNames } from "../s3.js";
|
|
1
|
+
import { resolveBucketName, getAvailableBucketNames } from "../services/s3.service.js";
|
|
2
|
+
import { renderPage, mbkautheVar } from "mbkauthe";
|
|
7
3
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Render the main bucket dashboard page.
|
|
6
|
+
*/
|
|
7
|
+
export function renderBucketDashboard(req, res) {
|
|
11
8
|
const allBuckets = getAvailableBucketNames();
|
|
12
9
|
const configuredDefaultBucket = mbkautheVar?.bucket;
|
|
13
10
|
|
|
14
11
|
let bucketfilePath;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
bucketfilePath ="bucketportal.handlebars";
|
|
12
|
+
if (mbkautheVar?.APP_NAME === "portal") {
|
|
13
|
+
bucketfilePath = "bucketportal.handlebars";
|
|
18
14
|
} else {
|
|
19
15
|
bucketfilePath = "bucket.handlebars";
|
|
20
16
|
}
|
|
21
17
|
|
|
22
|
-
|
|
23
18
|
let selectedBucket;
|
|
24
19
|
|
|
25
20
|
try {
|
|
@@ -61,26 +56,3 @@ function renderBucketDashboard(req, res) {
|
|
|
61
56
|
availableBuckets: allBuckets
|
|
62
57
|
});
|
|
63
58
|
}
|
|
64
|
-
|
|
65
|
-
router.get('/mbkbucket', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
66
|
-
return renderBucketDashboard(req, res);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
// Central bucket resolution for all /mbkbucket subroutes.
|
|
70
|
-
router.use('/mbkbucket', (req, _res, next) => {
|
|
71
|
-
try {
|
|
72
|
-
req.activeBucket = resolveBucketName(req.query.bucket);
|
|
73
|
-
req.bucketResolveError = null;
|
|
74
|
-
} catch (err) {
|
|
75
|
-
req.activeBucket = null;
|
|
76
|
-
req.bucketResolveError = err;
|
|
77
|
-
}
|
|
78
|
-
next();
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
router.use("/mbkbucket", infoRoutes);
|
|
83
|
-
router.use("/mbkbucket", viewRoutes);
|
|
84
|
-
router.use("/mbkbucket", apiRoutes);
|
|
85
|
-
|
|
86
|
-
export default router;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { renderPage, mbkautheVar } from "mbkauthe";
|
|
2
|
+
import { packageJson, getLatestVersion } from "../config/index.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Render the mbkbucket info page (Handlebars).
|
|
6
|
+
*/
|
|
7
|
+
export async function infoPage(req, res) {
|
|
8
|
+
let latestVersion = 'unknown';
|
|
9
|
+
try {
|
|
10
|
+
latestVersion = await getLatestVersion();
|
|
11
|
+
} catch (err) {
|
|
12
|
+
console.error("[mbkauthe] Error fetching package-lock.json:", err);
|
|
13
|
+
}
|
|
14
|
+
renderPage(req, res, "mbkbucket_info.handlebars", false, {
|
|
15
|
+
page: "MBK Bucket Info",
|
|
16
|
+
CurrentVersion: packageJson.version,
|
|
17
|
+
latestVersion,
|
|
18
|
+
APP_NAME: mbkautheVar?.APP_NAME
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Return bucket info as JSON.
|
|
24
|
+
*/
|
|
25
|
+
export async function infoJson(req, res) {
|
|
26
|
+
let latestVersion = 'unknown';
|
|
27
|
+
try {
|
|
28
|
+
latestVersion = await getLatestVersion();
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.error("[mbkauthe] Error fetching package-lock.json:", err);
|
|
31
|
+
}
|
|
32
|
+
res.json({
|
|
33
|
+
CurrentVersion: packageJson.version,
|
|
34
|
+
latestVersion,
|
|
35
|
+
APP_NAME: mbkautheVar?.APP_NAME
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import {
|
|
2
|
+
downloadFile, getFileMetadata, ensureKeyHasAppPrefix,
|
|
3
|
+
generateSignedUrl, resolveBucketName
|
|
4
|
+
} from "../services/s3.service.js";
|
|
5
|
+
import { mbkbucketVar } from "../config/index.js";
|
|
6
|
+
import {
|
|
7
|
+
escapeHtml, getBaseName, VIEWABLE_TYPES, VIEWABLE_TYPES_SORTED,
|
|
8
|
+
STATIC_ASSET_TYPES, VIDEO_TYPES, AUDIO_TYPES, MIME_TYPES,
|
|
9
|
+
buildCacheControl, parseRangeHeader, renderPlayerPage
|
|
10
|
+
} from "../utils/helpers.js";
|
|
11
|
+
import { createLogger } from "../utils/logger.js";
|
|
12
|
+
|
|
13
|
+
const debugView = createLogger('view');
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Stream helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
function attachStreamCleanup(req, res, stream, timeoutMs = 120000) {
|
|
20
|
+
const cleanup = () => {
|
|
21
|
+
if (stream && typeof stream.destroy === 'function') {
|
|
22
|
+
stream.removeAllListeners();
|
|
23
|
+
stream.destroy();
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
stream.on('error', (err) => {
|
|
28
|
+
console.error('Stream error:', err);
|
|
29
|
+
cleanup();
|
|
30
|
+
if (!res.headersSent) res.status(500).end('Stream error');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
req.on('close', cleanup);
|
|
34
|
+
req.on('aborted', cleanup);
|
|
35
|
+
|
|
36
|
+
req.setTimeout(timeoutMs, () => {
|
|
37
|
+
console.error('Request timeout for stream');
|
|
38
|
+
cleanup();
|
|
39
|
+
if (!res.headersSent) res.status(408).end('Request timeout');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
res.on('finish', cleanup);
|
|
43
|
+
res.on('close', cleanup);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Inline file serving (used by /view/:key and /p_view/:key)
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName, publicCache = false } = {}) {
|
|
51
|
+
const fileName = getBaseName(keyToUse);
|
|
52
|
+
const dotIndex = fileName.lastIndexOf('.');
|
|
53
|
+
const fileExtension = dotIndex === -1 ? '' : fileName.substring(dotIndex + 1).toLowerCase();
|
|
54
|
+
|
|
55
|
+
debugView('Request for %s (type: %s, range: %s)', fileName, fileExtension, req.headers.range || 'none');
|
|
56
|
+
|
|
57
|
+
if (!VIEWABLE_TYPES.has(fileExtension)) {
|
|
58
|
+
return res.status(415).json({
|
|
59
|
+
message: 'File type not supported for viewing',
|
|
60
|
+
supportedTypes: VIEWABLE_TYPES_SORTED
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ifNoneMatch = req.headers['if-none-match'];
|
|
65
|
+
const ifModifiedSince = req.headers['if-modified-since'];
|
|
66
|
+
const rangeHeader = req.headers.range;
|
|
67
|
+
|
|
68
|
+
const isStaticAsset = STATIC_ASSET_TYPES.has(fileExtension);
|
|
69
|
+
const isVideo = VIDEO_TYPES.has(fileExtension);
|
|
70
|
+
const isAudio = AUDIO_TYPES.has(fileExtension);
|
|
71
|
+
const isPdf = fileExtension === 'pdf';
|
|
72
|
+
const supportsRanges = isVideo || isAudio || isPdf;
|
|
73
|
+
const contentType = MIME_TYPES[fileExtension] || 'application/octet-stream';
|
|
74
|
+
|
|
75
|
+
// Fast path for range requests (video / audio / PDF streaming)
|
|
76
|
+
if (supportsRanges && rangeHeader) {
|
|
77
|
+
debugView('Processing range request: %s for %s', rangeHeader, fileName);
|
|
78
|
+
let meta;
|
|
79
|
+
try {
|
|
80
|
+
meta = await getFileMetadata(keyToUse, bucketName);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error('[mbkbucket] serveFileInline metadata error:', err);
|
|
83
|
+
return res.status(500).json({ message: 'Failed to fetch file metadata', error: err.message });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!meta || !meta.exists) {
|
|
87
|
+
return res.status(404).json({ message: 'File not found', key: keyToUse });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const etag = `"${meta.LastModified?.getTime() || Date.now()}-${meta.ContentLength || 0}"`;
|
|
91
|
+
if (ifNoneMatch === etag) return res.status(304).end();
|
|
92
|
+
|
|
93
|
+
const total = Number(meta.ContentLength);
|
|
94
|
+
if (!total) {
|
|
95
|
+
// No content-length — fall through to full-file path
|
|
96
|
+
} else {
|
|
97
|
+
const range = parseRangeHeader(rangeHeader, total);
|
|
98
|
+
if (!range) {
|
|
99
|
+
// Invalid or unsatisfiable range
|
|
100
|
+
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
101
|
+
return res.end();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { start, end, size: chunksize } = range;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, bucketName });
|
|
108
|
+
|
|
109
|
+
if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
|
|
110
|
+
debugView('Range size mismatch for %s: expected=%s got=%s', keyToUse, chunksize, rangeResult.ContentLength);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
debugView('Serving range bytes %s-%s/%s (%s bytes) for %s', start, end, total, chunksize, fileName);
|
|
114
|
+
|
|
115
|
+
res.status(206);
|
|
116
|
+
res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
|
|
117
|
+
res.setHeader('Content-Length', chunksize);
|
|
118
|
+
res.setHeader('Content-Type', contentType);
|
|
119
|
+
res.setHeader('Cache-Control', buildCacheControl(contentType, { supportsRanges: true, publicCache }));
|
|
120
|
+
res.setHeader('ETag', etag);
|
|
121
|
+
res.setHeader('Accept-Ranges', 'bytes');
|
|
122
|
+
if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
|
|
123
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
124
|
+
if (meta.LastModified) res.setHeader('Last-Modified', meta.LastModified.toUTCString());
|
|
125
|
+
|
|
126
|
+
attachStreamCleanup(req, res, rangeResult.Body);
|
|
127
|
+
return rangeResult.Body.pipe(res);
|
|
128
|
+
|
|
129
|
+
} catch (rangeError) {
|
|
130
|
+
console.error('Range request failed:', rangeError);
|
|
131
|
+
// fall through to full-file path
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Standard path: full-file download
|
|
137
|
+
const downloadOptions = {
|
|
138
|
+
...(ifNoneMatch && { ifNoneMatch }),
|
|
139
|
+
...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) }),
|
|
140
|
+
bucketName
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
if (isPdf) {
|
|
144
|
+
debugView('Serving full PDF (no range header) for %s', fileName);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let result;
|
|
148
|
+
try {
|
|
149
|
+
result = await downloadFile(keyToUse, downloadOptions);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (err.name === 'NoSuchKey' || err.name === 'NotFound') {
|
|
152
|
+
return res.status(404).json({ message: 'File not found', key: keyToUse });
|
|
153
|
+
} else if (err.name === 'AccessDenied') {
|
|
154
|
+
return res.status(403).json({ message: 'Access denied' });
|
|
155
|
+
}
|
|
156
|
+
console.error('[mbkbucket] serveFileInline download error:', err);
|
|
157
|
+
return res.status(500).json({ message: 'Failed to fetch file', error: err.message });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (result && result.notModified) return res.status(304).end();
|
|
161
|
+
|
|
162
|
+
const etag = `"${result.LastModified?.getTime() || Date.now()}-${result.ContentLength || 0}"`;
|
|
163
|
+
if (ifNoneMatch === etag) return res.status(304).end();
|
|
164
|
+
|
|
165
|
+
res.setHeader('Content-Type', contentType);
|
|
166
|
+
res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
|
|
167
|
+
res.setHeader('ETag', etag);
|
|
168
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
169
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
170
|
+
if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
|
|
171
|
+
if (result.ContentLength) res.setHeader('Content-Length', result.ContentLength);
|
|
172
|
+
if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
|
|
173
|
+
if (fileExtension === 'html' || fileExtension === 'htm') {
|
|
174
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
|
|
175
|
+
}
|
|
176
|
+
if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
|
|
177
|
+
|
|
178
|
+
res.setHeader('Cache-Control', buildCacheControl(contentType, { isStaticAsset, supportsRanges, publicCache }));
|
|
179
|
+
|
|
180
|
+
res.status(200);
|
|
181
|
+
|
|
182
|
+
const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
|
|
183
|
+
attachStreamCleanup(req, res, result.Body, timeoutDuration);
|
|
184
|
+
|
|
185
|
+
result.Body.pipe(res);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Controller handlers
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
export async function viewFile(req, res) {
|
|
193
|
+
try {
|
|
194
|
+
const bucketName = req.activeBucket;
|
|
195
|
+
let keyToUse;
|
|
196
|
+
try {
|
|
197
|
+
keyToUse = ensureKeyHasAppPrefix(req.params.key);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return res.status(400).json({ message: e.message });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: false });
|
|
203
|
+
|
|
204
|
+
} catch (error) {
|
|
205
|
+
console.error("Error viewing file:", error);
|
|
206
|
+
if (error.name === 'NoSuchKey' || error.name === 'NotFound') {
|
|
207
|
+
res.status(404).json({ message: "File not found", key: req.params.key });
|
|
208
|
+
} else if (error.name === 'AccessDenied') {
|
|
209
|
+
res.status(403).json({ message: "Access denied" });
|
|
210
|
+
} else {
|
|
211
|
+
res.status(500).json({ message: "View failed", error: error.message });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function playerPage(req, res) {
|
|
217
|
+
try {
|
|
218
|
+
const bucketName = req.activeBucket;
|
|
219
|
+
const key = req.params.key;
|
|
220
|
+
let keyToUse;
|
|
221
|
+
try {
|
|
222
|
+
keyToUse = ensureKeyHasAppPrefix(key);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
return res.status(400).send(e.message);
|
|
225
|
+
}
|
|
226
|
+
const encoded = encodeURIComponent(keyToUse);
|
|
227
|
+
const safeFileName = escapeHtml(getBaseName(keyToUse));
|
|
228
|
+
const bucketQuery = `?bucket=${encodeURIComponent(bucketName)}`;
|
|
229
|
+
const html = renderPlayerPage(safeFileName, encoded, bucketQuery);
|
|
230
|
+
|
|
231
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
232
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
233
|
+
return res.status(200).send(html);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
console.error('Error rendering player page:', err);
|
|
236
|
+
return res.status(500).send('Failed to render player');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function publicView(req, res) {
|
|
241
|
+
try {
|
|
242
|
+
const bucketName = resolveBucketName();
|
|
243
|
+
const key = req.params.key;
|
|
244
|
+
|
|
245
|
+
// No directory indexing
|
|
246
|
+
if (!key || key.endsWith('/')) {
|
|
247
|
+
return res.status(403).json({ message: 'Directory indexing is not allowed' });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let keyToUse;
|
|
251
|
+
try {
|
|
252
|
+
keyToUse = ensureKeyHasAppPrefix(key);
|
|
253
|
+
} catch (e) {
|
|
254
|
+
return res.status(400).json({ message: e.message });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let p_view_inline = !!mbkbucketVar?.p_view_inline;
|
|
258
|
+
|
|
259
|
+
if (!p_view_inline) {
|
|
260
|
+
// Signed URL mode
|
|
261
|
+
try {
|
|
262
|
+
const signed = await generateSignedUrl(keyToUse, 'getObject', 3600, bucketName);
|
|
263
|
+
return res.redirect(302, signed.url);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.error('[mbkbucket] p_view signed URL error:', err);
|
|
266
|
+
return res.status(500).json({ message: 'Failed to generate access URL' });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Direct serve mode
|
|
271
|
+
await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: true });
|
|
272
|
+
} catch (error) {
|
|
273
|
+
console.error('[mbkbucket] p_view error:', error);
|
|
274
|
+
res.status(500).json({ message: 'Internal server error', error: error.message });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps an async Express route handler so that any thrown/rejected errors
|
|
3
|
+
* are forwarded to `next(err)` instead of crashing the process.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* router.get('/path', wrap(myAsyncHandler));
|
|
7
|
+
*
|
|
8
|
+
* If a custom error handler is provided, it receives (error, req, res, next)
|
|
9
|
+
* and can send a custom response instead of forwarding to Express error middleware.
|
|
10
|
+
*/
|
|
11
|
+
export function wrap(handler, onError) {
|
|
12
|
+
return (req, res, next) => {
|
|
13
|
+
const result = handler(req, res, next);
|
|
14
|
+
if (result && typeof result.catch === 'function') {
|
|
15
|
+
result.catch((err) => {
|
|
16
|
+
if (onError) {
|
|
17
|
+
return onError(err, req, res, next);
|
|
18
|
+
}
|
|
19
|
+
next(err);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Combines wrap() with a standard JSON error response for API routes.
|
|
27
|
+
* The `fallbackMessage` is used when the error has no message.
|
|
28
|
+
*/
|
|
29
|
+
export function wrapApi(handler, fallbackMessage = 'Request failed') {
|
|
30
|
+
return wrap(handler, (err, _req, res) => {
|
|
31
|
+
console.error(`[mbkbucket] ${err.stack || err.message}`);
|
|
32
|
+
res.status(500).json({
|
|
33
|
+
success: false,
|
|
34
|
+
code: 'INTERNAL_ERROR',
|
|
35
|
+
error: err.message || fallbackMessage,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { resolveBucketName } from "../services/s3.service.js";
|
|
2
|
+
import { sendApiError, sendViewError } from "../utils/errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Middleware that resolves `?bucket=<name>` query param and attaches
|
|
6
|
+
* `req.activeBucket` and `req.bucketResolveError` to every request under /mbkbucket.
|
|
7
|
+
*/
|
|
8
|
+
export function bucketResolver(req, _res, next) {
|
|
9
|
+
try {
|
|
10
|
+
req.activeBucket = resolveBucketName(req.query.bucket);
|
|
11
|
+
req.bucketResolveError = null;
|
|
12
|
+
} catch (err) {
|
|
13
|
+
req.activeBucket = null;
|
|
14
|
+
req.bucketResolveError = err;
|
|
15
|
+
}
|
|
16
|
+
next();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Guard middleware for API routes — returns 400/503 JSON if bucket is invalid.
|
|
21
|
+
*/
|
|
22
|
+
export function requireBucketApi(req, res, next) {
|
|
23
|
+
if (req.bucketResolveError) {
|
|
24
|
+
return sendApiError(res, req.bucketResolveError, 'Invalid bucket selection');
|
|
25
|
+
}
|
|
26
|
+
if (!req.activeBucket) {
|
|
27
|
+
return sendApiError(res, new Error('No bucket selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.'));
|
|
28
|
+
}
|
|
29
|
+
next();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Guard middleware for view routes — returns 400/503 JSON if bucket is invalid.
|
|
34
|
+
* Skips public view routes (/p_view/...) which use the default bucket.
|
|
35
|
+
*/
|
|
36
|
+
export function requireBucketView(req, res, next) {
|
|
37
|
+
if (req.path.startsWith('/p_view/')) return next();
|
|
38
|
+
if (req.bucketResolveError) return sendViewError(res, req.bucketResolveError, 'Invalid bucket selection');
|
|
39
|
+
if (!req.activeBucket) {
|
|
40
|
+
return sendViewError(res, new Error('No bucket selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.'));
|
|
41
|
+
}
|
|
42
|
+
next();
|
|
43
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createLogger } from "../utils/logger.js";
|
|
2
|
+
|
|
3
|
+
const debugServer = createLogger('server');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Global 404 handler — renders error page via Handlebars.
|
|
7
|
+
*/
|
|
8
|
+
export function notFoundHandler(req, res) {
|
|
9
|
+
debugServer("Path not found: %s %s", req.method, req.url);
|
|
10
|
+
return res.status(404).render("Error/dError.handlebars", {
|
|
11
|
+
layout: false,
|
|
12
|
+
code: 404,
|
|
13
|
+
error: "Not Found",
|
|
14
|
+
message: "The requested page was not found.",
|
|
15
|
+
pagename: "Home",
|
|
16
|
+
page: "/",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Global error handler — catches unhandled errors from routes/middleware.
|
|
22
|
+
*/
|
|
23
|
+
export function globalErrorHandler(err, req, res, _next) {
|
|
24
|
+
console.error(`[mbkbucket] ${err.stack}`);
|
|
25
|
+
return res.status(500).render("Error/dError.handlebars", {
|
|
26
|
+
layout: false,
|
|
27
|
+
code: 500,
|
|
28
|
+
error: "Internal app Error",
|
|
29
|
+
message: "An unexpected error occurred on the app.",
|
|
30
|
+
details: err.message,
|
|
31
|
+
pagename: "Home",
|
|
32
|
+
page: "/",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import rateLimit from "express-rate-limit";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* General rate limiter for typical browsing/API usage.
|
|
5
|
+
* 150 requests per 2 minutes per IP.
|
|
6
|
+
*/
|
|
7
|
+
export const generalLimiter = rateLimit({
|
|
8
|
+
windowMs: 2 * 60 * 1000, // 2 minutes
|
|
9
|
+
max: 150,
|
|
10
|
+
standardHeaders: true,
|
|
11
|
+
legacyHeaders: false,
|
|
12
|
+
handler: (req, res) => {
|
|
13
|
+
res.status(429).render('error.handlebars', { message: 'Too many requests from your IP. Try again later.', code: 429 });
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Stricter rate limiter for public view (p_view) routes.
|
|
19
|
+
* 10 requests per minute per IP.
|
|
20
|
+
*/
|
|
21
|
+
export const pviewRateLimit = rateLimit({
|
|
22
|
+
windowMs: 60 * 1000, // 1 minute
|
|
23
|
+
max: 10,
|
|
24
|
+
standardHeaders: 'draft-7',
|
|
25
|
+
legacyHeaders: false,
|
|
26
|
+
keyGenerator: (req) => req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown',
|
|
27
|
+
handler: (_req, res) => {
|
|
28
|
+
res.status(429).json({ message: 'Too many requests. Please slow down and try again later.' });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security middleware for public view (p_view) routes.
|
|
3
|
+
* Blocks bot/scraper User-Agents and sets security headers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
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
|
+
|
|
8
|
+
const SUSPICIOUS_HEADERS = [
|
|
9
|
+
'x-devtools-emulate-network-conditions-client-id',
|
|
10
|
+
'x-chromedriver-clientid',
|
|
11
|
+
'x-automated-tool'
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* p_view security middleware: blocks bots, sets security headers.
|
|
16
|
+
*/
|
|
17
|
+
export function pviewSecurity(req, res, next) {
|
|
18
|
+
const ua = req.headers['user-agent'] || '';
|
|
19
|
+
|
|
20
|
+
// Block empty or obviously automated UAs
|
|
21
|
+
if (!ua || BLOCKED_UA_PATTERNS.test(ua)) {
|
|
22
|
+
return res.status(403).json({ message: 'Forbidden' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Block suspicious headers that indicate automation
|
|
26
|
+
const hasSuspiciousHeaders = SUSPICIOUS_HEADERS.some(header => header in req.headers);
|
|
27
|
+
if (hasSuspiciousHeaders) {
|
|
28
|
+
return res.status(403).json({ message: 'Forbidden' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Verify Accept header is present (legitimate browsers always send this)
|
|
32
|
+
if (!req.headers['accept']) {
|
|
33
|
+
return res.status(403).json({ message: 'Forbidden' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Set security headers for the public view response
|
|
37
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
38
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
39
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
40
|
+
res.removeHeader('Access-Control-Allow-Origin');
|
|
41
|
+
|
|
42
|
+
next();
|
|
43
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { sessRole } from "mbkauthe";
|
|
3
|
+
import multer from "multer";
|
|
4
|
+
import { requireBucketApi } from "../middleware/bucket-resolver.js";
|
|
5
|
+
import {
|
|
6
|
+
cleanupUploads, listIncompleteUploads, listFiles,
|
|
7
|
+
uploadSingleFile, initiateMultipartUpload, uploadChunk,
|
|
8
|
+
completeUpload, abortUpload, createFolder, deleteItems,
|
|
9
|
+
downloadFileHandler
|
|
10
|
+
} from "../controllers/api.controller.js";
|
|
11
|
+
|
|
12
|
+
const router = express.Router();
|
|
13
|
+
|
|
14
|
+
const MAX_CHUNK_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
15
|
+
|
|
16
|
+
const upload = multer({
|
|
17
|
+
storage: multer.memoryStorage(),
|
|
18
|
+
limits: { fileSize: MAX_CHUNK_SIZE }
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const uploadChunkMiddleware = (req, res, next) => {
|
|
22
|
+
upload.single('chunk')(req, res, (err) => {
|
|
23
|
+
if (err) {
|
|
24
|
+
if (err.code === 'LIMIT_FILE_SIZE') {
|
|
25
|
+
return res.status(413).json({
|
|
26
|
+
success: false,
|
|
27
|
+
error: `Chunk too large (max ${Math.round(MAX_CHUNK_SIZE / (1024 * 1024))} MB).`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return next(err);
|
|
31
|
+
}
|
|
32
|
+
next();
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Bucket guard for all API routes
|
|
37
|
+
router.use(requireBucketApi);
|
|
38
|
+
|
|
39
|
+
// Multipart upload cleanup
|
|
40
|
+
router.post('/api/cleanup-uploads', sessRole('SuperAdmin'), cleanupUploads);
|
|
41
|
+
|
|
42
|
+
// List incomplete multipart uploads
|
|
43
|
+
router.get('/api/incomplete-uploads', sessRole('SuperAdmin'), listIncompleteUploads);
|
|
44
|
+
|
|
45
|
+
// List files
|
|
46
|
+
router.get('/api/files', sessRole('SuperAdmin'), listFiles);
|
|
47
|
+
|
|
48
|
+
// Single-file upload
|
|
49
|
+
router.post('/upload', sessRole('SuperAdmin'), upload.single('file'), uploadSingleFile);
|
|
50
|
+
|
|
51
|
+
// Multipart upload flow
|
|
52
|
+
router.post('/upload-init', sessRole('SuperAdmin'), initiateMultipartUpload);
|
|
53
|
+
router.post('/upload-chunk', sessRole('SuperAdmin'), uploadChunkMiddleware, uploadChunk);
|
|
54
|
+
router.post('/upload-complete', sessRole('SuperAdmin'), completeUpload);
|
|
55
|
+
router.post('/upload-abort', sessRole('SuperAdmin'), abortUpload);
|
|
56
|
+
|
|
57
|
+
// Folder operations
|
|
58
|
+
router.post('/create-folder', sessRole('SuperAdmin'), createFolder);
|
|
59
|
+
|
|
60
|
+
// Delete operations
|
|
61
|
+
router.post('/delete', sessRole('SuperAdmin'), deleteItems);
|
|
62
|
+
|
|
63
|
+
// Download
|
|
64
|
+
router.get('/download/:key(*)', sessRole('SuperAdmin'), downloadFileHandler);
|
|
65
|
+
|
|
66
|
+
export default router;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { sessRole } from "mbkauthe";
|
|
3
|
+
import { bucketResolver } from "../middleware/bucket-resolver.js";
|
|
4
|
+
import { renderBucketDashboard } from "../controllers/dashboard.controller.js";
|
|
5
|
+
import infoRoutes from "./info.routes.js";
|
|
6
|
+
import viewRoutes from "./view.routes.js";
|
|
7
|
+
import apiRoutes from "./api.routes.js";
|
|
8
|
+
|
|
9
|
+
const router = express.Router();
|
|
10
|
+
|
|
11
|
+
// Dashboard home
|
|
12
|
+
router.get('/mbkbucket', sessRole('SuperAdmin'), renderBucketDashboard);
|
|
13
|
+
|
|
14
|
+
// Central bucket resolution for all /mbkbucket subroutes
|
|
15
|
+
router.use('/mbkbucket', bucketResolver);
|
|
16
|
+
|
|
17
|
+
// Mount sub-routers
|
|
18
|
+
router.use("/mbkbucket", infoRoutes);
|
|
19
|
+
router.use("/mbkbucket", viewRoutes);
|
|
20
|
+
router.use("/mbkbucket", apiRoutes);
|
|
21
|
+
|
|
22
|
+
export default router;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { sessRole } from "mbkauthe";
|
|
3
|
+
import { infoPage, infoJson } from "../controllers/info.controller.js";
|
|
4
|
+
|
|
5
|
+
const router = express.Router();
|
|
6
|
+
|
|
7
|
+
router.get(['/info', "/i"], infoPage);
|
|
8
|
+
router.get(['/info.json', "/i.json"], infoJson);
|
|
9
|
+
|
|
10
|
+
export default router;
|