mbkbucket 1.3.0 → 1.5.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.
@@ -0,0 +1,28 @@
1
+ import express from "express";
2
+ import { renderPage, mbkautheVar } from "mbkauthe";
3
+ import { packageJson, getLatestVersion } from "../config/index.js";
4
+
5
+ const router = express.Router();
6
+
7
+ router.get(['/info', "/i"], async (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, { page: "MBK Bucket Info", CurrentVersion: packageJson.version, latestVersion, APP_NAME: mbkautheVar?.APP_NAME });
15
+ });
16
+
17
+
18
+ router.get(['/info.json', "/i.json"], async (req, res) => {
19
+ let latestVersion = 'unknown';
20
+ try {
21
+ latestVersion = await getLatestVersion();
22
+ } catch (err) {
23
+ console.error("[mbkauthe] Error fetching package-lock.json:", err);
24
+ }
25
+ res.json({CurrentVersion:packageJson.version, latestVersion, APP_NAME: mbkautheVar?.APP_NAME });
26
+ });
27
+
28
+ export default router;
@@ -0,0 +1,458 @@
1
+ import express from "express";
2
+ import { validateSessionAndRole } from "mbkauthe";
3
+ import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, generateSignedUrl } from "../s3.js";
4
+ import { mbkbucketVar } from "../config/index.js";
5
+ import rateLimit from "express-rate-limit";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // p_view security: rate limiter + basic bot/security middleware
9
+ // ---------------------------------------------------------------------------
10
+ const pviewRateLimit = rateLimit({
11
+ windowMs: 60 * 1000, // 1-minute window
12
+ max: 10, // max 10 requests per IP per minute
13
+ standardHeaders: 'draft-7',
14
+ legacyHeaders: false,
15
+ keyGenerator: (req) => req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown',
16
+ handler: (_req, res) => {
17
+ res.status(429).json({ message: 'Too many requests. Please slow down and try again later.' });
18
+ }
19
+ });
20
+
21
+ // Blocks obviously headless/scraper User-Agents and missing UAs
22
+ 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;
23
+
24
+ // Suspicious header patterns that indicate automated access
25
+ const SUSPICIOUS_HEADERS = [
26
+ 'x-devtools-emulate-network-conditions-client-id',
27
+ 'x-chromedriver-clientid',
28
+ 'x-automated-tool'
29
+ ];
30
+
31
+ function pviewSecurity(req, res, next) {
32
+ const ua = req.headers['user-agent'] || '';
33
+
34
+ // Block empty or obviously automated UAs
35
+ if (!ua || BLOCKED_UA_PATTERNS.test(ua)) {
36
+ return res.status(403).json({ message: 'Forbidden' });
37
+ }
38
+
39
+ // Block suspicious headers that indicate automation
40
+ const hasSuspiciousHeaders = SUSPICIOUS_HEADERS.some(header => header in req.headers);
41
+ if (hasSuspiciousHeaders) {
42
+ return res.status(403).json({ message: 'Forbidden' });
43
+ }
44
+
45
+ // Rate limit check - ensure unique fingerprint per request
46
+ const fingerprint = `${req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress}:${ua}`;
47
+
48
+ // Verify Accept header is present (legitimate browsers always send this)
49
+ if (!req.headers['accept']) {
50
+ return res.status(403).json({ message: 'Forbidden' });
51
+ }
52
+
53
+ // Set security headers for the public view response
54
+ res.setHeader('X-Content-Type-Options', 'nosniff');
55
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
56
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
57
+ // Fix CORS - either allow all (public files) or restrict to specific origins
58
+ // Using 'null' is invalid; we'll disable CORS entirely for public view since it's auth-restricted
59
+ res.removeHeader('Access-Control-Allow-Origin');
60
+
61
+ next();
62
+ }
63
+
64
+ const router = express.Router();
65
+
66
+ // New: lightweight player page for robust playback in browsers
67
+ router.get('/player/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
68
+ try {
69
+ const key = req.params.key;
70
+ let keyToUse;
71
+ try {
72
+ keyToUse = ensureKeyHasAppPrefix(key);
73
+ } catch (e) {
74
+ return res.status(400).send(e.message);
75
+ }
76
+ const encoded = encodeURIComponent(keyToUse);
77
+ const fileName = keyToUse.split('/').pop();
78
+
79
+ const html = `<!doctype html>
80
+ <html lang="en">
81
+ <head>
82
+ <meta charset="utf-8">
83
+ <meta name="viewport" content="width=device-width,initial-scale=1">
84
+ <title>Preview: ${fileName}</title>
85
+ <style>
86
+ * { margin: 0; padding: 0; box-sizing: border-box; }
87
+ body { margin: 0; display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #000; color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
88
+ .player-container { width: 100%; max-width: 100vw; max-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
89
+ video { width: 100%; height: auto; max-width: 100%; max-height: calc(100vh - 40px); object-fit: contain; background: #000; }
90
+ .file-name { position: fixed; top: 10px; left: 10px; background: rgba(0, 0, 0, 0.8); padding: 8px 16px; border-radius: 4px; font-size: 14px; z-index: 10; }
91
+ </style>
92
+ </head>
93
+ <body>
94
+ <div class="file-name">${fileName}</div>
95
+ <div class="player-container">
96
+ <video controls preload="metadata" src="/mbkbucket/view/${encoded}">
97
+ Your browser does not support the video tag.
98
+ </video>
99
+ </div>
100
+ </body>
101
+ </html>`;
102
+
103
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
104
+ res.setHeader('X-Content-Type-Options', 'nosniff');
105
+ return res.status(200).send(html);
106
+ } catch (err) {
107
+ console.error('Error rendering player page:', err);
108
+ return res.status(500).send('Failed to render player');
109
+ }
110
+ });
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Shared constants
114
+ // ---------------------------------------------------------------------------
115
+ const VIEWABLE_TYPES = new Set([
116
+ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico',
117
+ 'mp4', 'webm', 'ogg', 'avi', 'mov',
118
+ 'mp3', 'wav', 'flac', 'aac', 'm4a',
119
+ 'txt', 'md', 'json', 'xml', 'csv', 'log',
120
+ 'js', 'ts', 'html', 'htm', 'css', 'php', 'py', 'java', 'cpp', 'c', 'h',
121
+ 'cs', 'rb', 'go', 'rs', 'sql', 'sh', 'bat', 'ps1', 'yaml', 'yml',
122
+ 'toml', 'ini', 'conf', 'pdf'
123
+ ]);
124
+ // Pre-sorted for 415 error responses (avoids re-sorting on every error)
125
+ const VIEWABLE_TYPES_SORTED = Array.from(VIEWABLE_TYPES).sort();
126
+
127
+ const MIME_TYPES = {
128
+ // Images
129
+ 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
130
+ 'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
131
+ 'bmp': 'image/bmp', 'ico': 'image/x-icon',
132
+ // Videos
133
+ 'mp4': 'video/mp4', 'webm': 'video/webm', 'ogg': 'video/ogg',
134
+ 'avi': 'video/x-msvideo', 'mov': 'video/quicktime',
135
+ // Audio
136
+ 'mp3': 'audio/mpeg', 'wav': 'audio/wav', 'flac': 'audio/flac',
137
+ 'aac': 'audio/aac', 'm4a': 'audio/mp4',
138
+ // Text / Code
139
+ 'txt': 'text/plain; charset=utf-8', 'md': 'text/markdown; charset=utf-8',
140
+ 'json': 'application/json; charset=utf-8', 'xml': 'application/xml; charset=utf-8',
141
+ 'csv': 'text/csv; charset=utf-8', 'log': 'text/plain; charset=utf-8',
142
+ 'js': 'text/javascript; charset=utf-8', 'ts': 'text/typescript; charset=utf-8',
143
+ 'html': 'text/html; charset=utf-8', 'htm': 'text/html; charset=utf-8',
144
+ 'css': 'text/css; charset=utf-8', 'php': 'text/x-php; charset=utf-8',
145
+ 'py': 'text/x-python; charset=utf-8', 'java': 'text/x-java-source; charset=utf-8',
146
+ 'cpp': 'text/x-c++src; charset=utf-8', 'c': 'text/x-csrc; charset=utf-8',
147
+ 'h': 'text/x-chdr; charset=utf-8', 'cs': 'text/x-csharp; charset=utf-8',
148
+ 'rb': 'text/x-ruby; charset=utf-8', 'go': 'text/x-go; charset=utf-8',
149
+ 'rs': 'text/x-rust; charset=utf-8', 'sql': 'text/x-sql; charset=utf-8',
150
+ 'sh': 'text/x-shellscript; charset=utf-8', 'bat': 'text/x-msdos-batch; charset=utf-8',
151
+ 'ps1': 'text/x-powershell; charset=utf-8', 'yaml': 'text/x-yaml; charset=utf-8',
152
+ 'yml': 'text/x-yaml; charset=utf-8', 'toml': 'text/x-toml; charset=utf-8',
153
+ 'ini': 'text/x-ini; charset=utf-8', 'conf': 'text/x-conf; charset=utf-8',
154
+ // Documents
155
+ 'pdf': 'application/pdf'
156
+ };
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Shared inline-serve helper
160
+ // Options:
161
+ // noindex {boolean} – adds X-Robots-Tag: noindex, nofollow (default false)
162
+ // ---------------------------------------------------------------------------
163
+ async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
164
+ const fileName = keyToUse.split('/').pop();
165
+ const fileExtension = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : '';
166
+
167
+ console.log(`[mbkbucket] 📥 Request for ${fileName} (type: ${fileExtension}, range: ${req.headers.range || 'none'})`);
168
+
169
+ if (!VIEWABLE_TYPES.has(fileExtension)) {
170
+ return res.status(415).json({
171
+ message: 'File type not supported for viewing',
172
+ supportedTypes: VIEWABLE_TYPES_SORTED
173
+ });
174
+ }
175
+
176
+ const ifNoneMatch = req.headers['if-none-match'];
177
+ const ifModifiedSince = req.headers['if-modified-since'];
178
+ const rangeHeader = req.headers.range;
179
+
180
+ const isStaticAsset = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'css', 'js'].includes(fileExtension);
181
+ const isVideo = ['mp4', 'webm', 'ogg', 'avi', 'mov'].includes(fileExtension);
182
+ const isAudio = ['mp3', 'wav', 'flac', 'aac', 'm4a'].includes(fileExtension);
183
+ const isPdf = fileExtension === 'pdf';
184
+ const supportsRanges = isVideo || isAudio || isPdf;
185
+ const contentType = MIME_TYPES[fileExtension] || 'application/octet-stream';
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Fast path for range requests (video / audio / PDF streaming)
189
+ // Use a cheap HEAD (getFileMetadata) to get size + ETag, then fetch ONLY the
190
+ // requested byte range — avoids downloading the full file first.
191
+ // ---------------------------------------------------------------------------
192
+ if (supportsRanges && rangeHeader) {
193
+ console.log(`[mbkbucket] Processing range request: ${rangeHeader} for ${fileName}`);
194
+ let meta;
195
+ try {
196
+ meta = await getFileMetadata(keyToUse);
197
+ } catch (err) {
198
+ console.error('[mbkbucket] serveFileInline metadata error:', err);
199
+ return res.status(500).json({ message: 'Failed to fetch file metadata', error: err.message });
200
+ }
201
+
202
+ if (!meta || !meta.exists) {
203
+ return res.status(404).json({ message: 'File not found', key: keyToUse });
204
+ }
205
+
206
+ const etag = `"${meta.LastModified?.getTime() || Date.now()}-${meta.ContentLength || 0}"`;
207
+ if (ifNoneMatch === etag) return res.status(304).end();
208
+
209
+ const total = Number(meta.ContentLength);
210
+ if (!total) {
211
+ // No content-length — fall through to full-file path
212
+ } else {
213
+ const CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB
214
+ const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
215
+ if (!match) {
216
+ res.status(416).setHeader('Content-Range', `bytes */${total}`);
217
+ return res.end();
218
+ }
219
+
220
+ const [, startStr, endStr] = match;
221
+ let start, end;
222
+
223
+ if (startStr === '') {
224
+ const suffix = parseInt(endStr, 10);
225
+ if (isNaN(suffix) || suffix <= 0) {
226
+ res.status(416).setHeader('Content-Range', `bytes */${total}`);
227
+ return res.end();
228
+ }
229
+ start = Math.max(0, total - suffix);
230
+ end = total - 1;
231
+ } else {
232
+ start = parseInt(startStr, 10);
233
+ if (isNaN(start) || start < 0) {
234
+ res.status(416).setHeader('Content-Range', `bytes */${total}`);
235
+ return res.end();
236
+ }
237
+ end = endStr === '' ? Math.min(start + CHUNK_SIZE - 1, total - 1) : parseInt(endStr, 10);
238
+ if (isNaN(end) || end < start) {
239
+ res.status(416).setHeader('Content-Range', `bytes */${total}`);
240
+ return res.end();
241
+ }
242
+ }
243
+
244
+ if (start >= total || start > end || end >= total) {
245
+ res.status(416).setHeader('Content-Range', `bytes */${total}`);
246
+ return res.end();
247
+ }
248
+
249
+ const chunksize = end - start + 1;
250
+
251
+ try {
252
+ const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}` });
253
+
254
+ if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
255
+ console.warn(`Range size mismatch for ${keyToUse}: expected=${chunksize} got=${rangeResult.ContentLength}`);
256
+ }
257
+
258
+ console.log(`[mbkbucket] ✓ Serving range bytes ${start}-${end}/${total} (${chunksize} bytes) for ${fileName}`);
259
+
260
+ res.status(206);
261
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
262
+ res.setHeader('Content-Length', chunksize);
263
+ res.setHeader('Content-Type', contentType);
264
+ res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate');
265
+ res.setHeader('ETag', etag);
266
+ res.setHeader('Accept-Ranges', 'bytes');
267
+ if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
268
+ res.setHeader('X-Content-Type-Options', 'nosniff');
269
+ if (meta.LastModified) res.setHeader('Last-Modified', meta.LastModified.toUTCString());
270
+
271
+ // Comprehensive stream error handling and cleanup
272
+ const cleanupStream = () => {
273
+ if (rangeResult.Body && typeof rangeResult.Body.destroy === 'function') {
274
+ rangeResult.Body.removeAllListeners();
275
+ rangeResult.Body.destroy();
276
+ }
277
+ };
278
+
279
+ rangeResult.Body.on('error', (err) => {
280
+ console.error('Range stream error:', err);
281
+ cleanupStream();
282
+ if (!res.headersSent) res.status(500).end('Stream error');
283
+ });
284
+
285
+ req.on('close', cleanupStream);
286
+ req.on('aborted', cleanupStream);
287
+
288
+ req.setTimeout(120000, () => {
289
+ console.error('Range request timeout for file:', keyToUse);
290
+ cleanupStream();
291
+ if (!res.headersSent) res.status(408).end('Request timeout');
292
+ });
293
+
294
+ res.on('finish', cleanupStream);
295
+ res.on('close', cleanupStream);
296
+
297
+ return rangeResult.Body.pipe(res);
298
+
299
+ } catch (rangeError) {
300
+ console.error('Range request failed:', rangeError);
301
+ // fall through to full-file path
302
+ }
303
+ }
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Standard path: full-file download (non-media, or media without Range header)
308
+ // ---------------------------------------------------------------------------
309
+ const downloadOptions = {
310
+ ...(ifNoneMatch && { ifNoneMatch }),
311
+ ...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) })
312
+ };
313
+
314
+ if (isPdf) {
315
+ console.log(`[mbkbucket] Serving full PDF (no range header) for ${fileName}`);
316
+ }
317
+
318
+ let result;
319
+ try {
320
+ result = await downloadFile(keyToUse, downloadOptions);
321
+ } catch (err) {
322
+ if (err.name === 'NoSuchKey' || err.name === 'NotFound') {
323
+ return res.status(404).json({ message: 'File not found', key: keyToUse });
324
+ } else if (err.name === 'AccessDenied') {
325
+ return res.status(403).json({ message: 'Access denied' });
326
+ }
327
+ console.error('[mbkbucket] serveFileInline download error:', err);
328
+ return res.status(500).json({ message: 'Failed to fetch file', error: err.message });
329
+ }
330
+
331
+ if (result && result.notModified) return res.status(304).end();
332
+
333
+ const etag = `"${result.LastModified?.getTime() || Date.now()}-${result.ContentLength || 0}"`;
334
+ if (ifNoneMatch === etag) return res.status(304).end();
335
+
336
+ res.setHeader('Content-Type', contentType);
337
+ res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
338
+ res.setHeader('ETag', etag);
339
+ res.setHeader('X-Content-Type-Options', 'nosniff');
340
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
341
+ if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
342
+ if (result.ContentLength) res.setHeader('Content-Length', result.ContentLength);
343
+ if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
344
+ if (fileExtension === 'html' || fileExtension === 'htm') {
345
+ res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
346
+ }
347
+ if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
348
+
349
+ if (isStaticAsset) {
350
+ res.setHeader('Cache-Control', 'public, max-age=3600, immutable');
351
+ } else if (supportsRanges) {
352
+ // For video, audio, and PDF - enable caching with revalidation
353
+ res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate');
354
+ } else {
355
+ res.setHeader('Cache-Control', 'public, max-age=300');
356
+ }
357
+
358
+ res.status(200);
359
+
360
+ // Comprehensive stream cleanup to prevent memory leaks
361
+ const cleanupStream = () => {
362
+ if (result.Body && typeof result.Body.destroy === 'function') {
363
+ result.Body.removeAllListeners();
364
+ result.Body.destroy();
365
+ }
366
+ };
367
+
368
+ result.Body.on('error', (err) => {
369
+ console.error('Stream error:', err);
370
+ cleanupStream();
371
+ if (!res.headersSent) res.status(500).json({ message: 'Error streaming file', error: err.message });
372
+ });
373
+
374
+ req.on('close', cleanupStream);
375
+ req.on('aborted', cleanupStream);
376
+
377
+ const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
378
+ req.setTimeout(timeoutDuration, () => {
379
+ console.error(`Request timeout (${timeoutDuration}ms) for file: ${keyToUse}, size: ${result.ContentLength}`);
380
+ cleanupStream();
381
+ if (!res.headersSent) res.status(408).json({ message: 'Request timeout' });
382
+ });
383
+
384
+ res.on('finish', cleanupStream);
385
+ res.on('close', cleanupStream);
386
+
387
+ result.Body.pipe(res);
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Routes
392
+ // ---------------------------------------------------------------------------
393
+
394
+ router.get('/view/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
395
+ try {
396
+ let keyToUse;
397
+ try {
398
+ keyToUse = ensureKeyHasAppPrefix(req.params.key);
399
+ } catch (e) {
400
+ return res.status(400).json({ message: e.message });
401
+ }
402
+
403
+ await serveFileInline(req, res, keyToUse, { noindex: true });
404
+
405
+ } catch (error) {
406
+ console.error("Error viewing file:", error);
407
+ if (error.name === 'NoSuchKey' || error.name === 'NotFound') {
408
+ res.status(404).json({ message: "File not found", key: req.params.key });
409
+ } else if (error.name === 'AccessDenied') {
410
+ res.status(403).json({ message: "Access denied" });
411
+ } else {
412
+ res.status(500).json({ message: "View failed", error: error.message });
413
+ }
414
+ }
415
+ });
416
+
417
+ if (mbkbucketVar?.publiView_enabled) {
418
+ router.get('/p_view/:key(*)', pviewRateLimit, pviewSecurity, async (req, res) => {
419
+ try {
420
+ const key = req.params.key;
421
+
422
+ // No directory indexing — reject folder-like or empty keys
423
+ if (!key || key.endsWith('/')) {
424
+ return res.status(403).json({ message: 'Directory indexing is not allowed' });
425
+ }
426
+
427
+ let keyToUse;
428
+ try {
429
+ keyToUse = ensureKeyHasAppPrefix(key);
430
+ } catch (e) {
431
+ return res.status(400).json({ message: e.message });
432
+ }
433
+
434
+ // Determine pview_b: check bucket config first, then env var
435
+ let p_view_inline = !!mbkbucketVar?.p_view_inline;
436
+
437
+
438
+ if (!p_view_inline) {
439
+ // Signed URL mode — redirect to a pre-signed S3 URL (1 hour expiry)
440
+ try {
441
+ const signed = await generateSignedUrl(keyToUse, 'getObject', 3600);
442
+ return res.redirect(302, signed.url);
443
+ } catch (err) {
444
+ console.error('[mbkbucket] p_view signed URL error:', err);
445
+ return res.status(500).json({ message: 'Failed to generate access URL' });
446
+ }
447
+ }
448
+
449
+ // Direct serve mode — stream file inline (with noindex header)
450
+ await serveFileInline(req, res, keyToUse, { noindex: true });
451
+ } catch (error) {
452
+ console.error('[mbkbucket] p_view error:', error);
453
+ res.status(500).json({ message: 'Internal server error', error: error.message });
454
+ }
455
+ });
456
+ }
457
+
458
+ export default router;