mbkbucket 1.4.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.
package/lib/bucket.js DELETED
@@ -1,979 +0,0 @@
1
- import express from "express";
2
- import { validateSessionAndRole, renderPage, mbkautheVar } from "mbkauthe";
3
- import { listfiles, uploadFile, deleteFile, deleteFiles, deleteFolder, downloadFile, getFileMetadata, fileExists, ensureKeyHasAppPrefix, ensurePrefix, getAppName, getBucketConfig, generateSignedUrl, createMultipartUpload, uploadPart, completeMultipartUpload, abortMultipartUpload, listIncompleteMultipartUploads, cleanupIncompleteMultipartUploads } from "./s3.js";
4
- import multer from "multer";
5
- import { mbkbucketVar } from "./config/index.js";
6
- import rateLimit from "express-rate-limit";
7
- import { appVersion, getLatestVersion } from "./config/index.js";
8
-
9
- // ---------------------------------------------------------------------------
10
- // p_view security: rate limiter + basic bot/security middleware
11
- // ---------------------------------------------------------------------------
12
- const pviewRateLimit = rateLimit({
13
- windowMs: 60 * 1000, // 1-minute window
14
- max: 10, // max 10 requests per IP per minute
15
- standardHeaders: 'draft-7',
16
- legacyHeaders: false,
17
- keyGenerator: (req) => req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown',
18
- handler: (_req, res) => {
19
- res.status(429).json({ message: 'Too many requests. Please slow down and try again later.' });
20
- }
21
- });
22
-
23
- // Blocks obviously headless/scraper User-Agents and missing UAs
24
- 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;
25
-
26
- // Suspicious header patterns that indicate automated access
27
- const SUSPICIOUS_HEADERS = [
28
- 'x-devtools-emulate-network-conditions-client-id',
29
- 'x-chromedriver-clientid',
30
- 'x-automated-tool'
31
- ];
32
-
33
- function pviewSecurity(req, res, next) {
34
- const ua = req.headers['user-agent'] || '';
35
-
36
- // Block empty or obviously automated UAs
37
- if (!ua || BLOCKED_UA_PATTERNS.test(ua)) {
38
- return res.status(403).json({ message: 'Forbidden' });
39
- }
40
-
41
- // Block suspicious headers that indicate automation
42
- const hasSuspiciousHeaders = SUSPICIOUS_HEADERS.some(header => header in req.headers);
43
- if (hasSuspiciousHeaders) {
44
- return res.status(403).json({ message: 'Forbidden' });
45
- }
46
-
47
- // Rate limit check - ensure unique fingerprint per request
48
- const fingerprint = `${req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress}:${ua}`;
49
-
50
- // Verify Accept header is present (legitimate browsers always send this)
51
- if (!req.headers['accept']) {
52
- return res.status(403).json({ message: 'Forbidden' });
53
- }
54
-
55
- // Set security headers for the public view response
56
- res.setHeader('X-Content-Type-Options', 'nosniff');
57
- res.setHeader('X-Frame-Options', 'SAMEORIGIN');
58
- res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
59
- // Fix CORS - either allow all (public files) or restrict to specific origins
60
- // Using 'null' is invalid; we'll disable CORS entirely for public view since it's auth-restricted
61
- res.removeHeader('Access-Control-Allow-Origin');
62
-
63
- next();
64
- }
65
-
66
- const router = express.Router();
67
- const upload = multer({ storage: multer.memoryStorage() });
68
-
69
- // Key/prefix helpers are provided by `s3.js`: `ensurePrefix`, `ensureKeyHasAppPrefix`, `getAppName`.
70
-
71
- router.get('/mbkbucket', validateSessionAndRole('SuperAdmin'), async (req, res) => {
72
- renderPage(req, res, "bucket.handlebars", false, { page: "Admin Bucket", bucketvar: mbkautheVar?.bucket, APP_NAME: mbkautheVar?.APP_NAME, message: req.query.message, error: req.query.error });
73
- });
74
-
75
- router.get(['/mbkbucket/info', "/mbkbucket/i"], validateSessionAndRole('SuperAdmin'), async (req, res) => {
76
- let latestVersion = 'unknown';
77
- try {
78
- latestVersion = await getLatestVersion();
79
- } catch (err) {
80
- console.error("[mbkauthe] Error fetching package-lock.json:", err);
81
- }
82
- renderPage(req, res, "mbkbucket_info.handlebars", false, { page: "MBK Bucket Info", CurrentVersion: appVersion, latestVersion, APP_NAME: mbkautheVar?.APP_NAME });
83
- });
84
-
85
-
86
- router.get(['/mbkbucket/info.json', "/mbkbucket/i.json"], validateSessionAndRole('SuperAdmin'), async (req, res) => {
87
- let latestVersion = 'unknown';
88
- try {
89
- latestVersion = await getLatestVersion();
90
- } catch (err) {
91
- console.error("[mbkauthe] Error fetching package-lock.json:", err);
92
- }
93
- res.json({CurrentVersion:appVersion, latestVersion, APP_NAME: mbkautheVar?.APP_NAME });
94
- });
95
-
96
- // API endpoint for multipart upload cleanup
97
- router.post('/mbkbucket/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async (req, res) => {
98
- try {
99
- const { olderThanDays = 7, prefix = '' } = req.body;
100
-
101
- // Validate olderThanDays
102
- const days = parseInt(olderThanDays, 10);
103
- if (isNaN(days) || days < 1) {
104
- return res.status(400).json({ success: false, error: 'olderThanDays must be a positive integer' });
105
- }
106
-
107
- console.log(`[mbkbucket] Starting cleanup of incomplete uploads older than ${days} days`);
108
- const result = await cleanupIncompleteMultipartUploads(days, prefix);
109
-
110
- res.json({
111
- success: true,
112
- message: `Cleaned up ${result.abortedCount} incomplete multipart upload(s)`,
113
- ...result
114
- });
115
- } catch (error) {
116
- console.error('[mbkbucket] Cleanup failed:', error);
117
- res.status(500).json({
118
- success: false,
119
- error: error.message
120
- });
121
- }
122
- });
123
-
124
- // API endpoint to list incomplete multipart uploads
125
- router.get('/mbkbucket/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), async (req, res) => {
126
- try {
127
- const { prefix = '' } = req.query;
128
- const uploads = await listIncompleteMultipartUploads(prefix);
129
-
130
- res.json({
131
- success: true,
132
- count: uploads.length,
133
- uploads: uploads.map(u => ({
134
- key: u.Key,
135
- uploadId: u.UploadId,
136
- initiated: u.Initiated,
137
- initiator: u.Initiator?.DisplayName
138
- }))
139
- });
140
- } catch (error) {
141
- console.error('[mbkbucket] Failed to list incomplete uploads:', error);
142
- res.status(500).json({
143
- success: false,
144
- error: error.message
145
- });
146
- }
147
- });
148
-
149
- // API endpoint for fetching files
150
- router.get('/mbkbucket/api/files', validateSessionAndRole('SuperAdmin'), async (req, res) => {
151
- const { prefix = '', page = '1', search = '', recursive = 'true', token = '' } = req.query;
152
- const pageSize = 100;
153
- let effectivePrefix;
154
- try {
155
- effectivePrefix = ensurePrefix(prefix);
156
- } catch (e) {
157
- return res.status(400).json({ success: false, error: e.message });
158
- }
159
-
160
- // Determine mode: Optimized (Folder View, No Search) vs Legacy/Deep (Flat View or Search)
161
- const isSearch = search && search.trim().length > 0;
162
- // If recursive is explicitly 'false', we respect it. Default to true if missing (though frontend should send it)
163
- // Logic: Optimization works only if NOT recursive and NOT searching.
164
- const isRecursive = recursive === 'true';
165
- const useOptimizedListing = !isSearch && !isRecursive;
166
-
167
- try {
168
- let files = [];
169
- let folders = []; // For CommonPrefixes (subfolders)
170
- let nextContinuationToken = null;
171
- let usedPrefix = effectivePrefix;
172
- let legacyFallback = false;
173
-
174
- if (useOptimizedListing) {
175
- // --- OPTIMIZED PATH: S3 Delimiter (Folder View) ---
176
- // We process only one page from S3. This reduces API calls significantly.
177
-
178
- // FIX: Ensure root listing uses trailing slash to prevent "app-name" matching "app-name-backup"
179
- // and ensure directory browsing works correctly if ensurePrefix didn't append slash (which it now does if present in input).
180
- // But for root, input is empty, ensurePrefix returns "app", so we force "app/" here.
181
- let listPrefix = effectivePrefix;
182
- if (listPrefix === getAppName() && !listPrefix.endsWith('/')) {
183
- listPrefix += '/';
184
- }
185
-
186
- const result = await listfiles(listPrefix, {
187
- continuationToken: token || undefined, // Use token from query if provided
188
- delimiter: '/',
189
- maxKeys: 1000 // Fetch a healthy batch.
190
- });
191
-
192
- files = result.Contents || [];
193
- // Filter out folder marker objects (zero-byte files ending with /)
194
- files = files.filter(f => !f.Key.endsWith('/'));
195
- // S3 returns CommonPrefixes for folders when delimiter is used
196
- folders = (result.CommonPrefixes || []).map(p => p.Prefix).filter(p => p !== effectivePrefix);
197
- nextContinuationToken = result.NextContinuationToken;
198
-
199
- // Note: In optimized mode, totalFiles is unknown without scanning everything.
200
- } else {
201
- // --- LEGACY PATH: Recursive Scan (Search or Flat View) ---
202
- let continuationToken = undefined;
203
-
204
- // Fetch all files recursively (handling S3 pagination)
205
- do {
206
- const result = await listfiles(effectivePrefix, { continuationToken });
207
- if (result.Contents) {
208
- // Filter out folder marker objects (zero-byte files ending with /)
209
- const actualFiles = result.Contents.filter(f => !f.Key.endsWith('/'));
210
- files = files.concat(actualFiles);
211
- }
212
- continuationToken = result.nextToken;
213
- } while (continuationToken);
214
-
215
- // If no files found under the app prefixed path, try root as a legacy fallback
216
- if (!files.length && !search) {
217
- try {
218
- const rootResult = await listfiles('');
219
- if ((rootResult.Contents || []).length) {
220
- files = rootResult.Contents || [];
221
- usedPrefix = '';
222
- legacyFallback = true;
223
- }
224
- } catch (fallbackErr) {
225
- console.warn('Fallback root listing failed:', fallbackErr.message);
226
- }
227
- }
228
-
229
- // Apply search filter if provided
230
- if (isSearch) {
231
- const searchLower = search.toLowerCase();
232
- files = files.filter(file => {
233
- return file.Key.toLowerCase().includes(searchLower);
234
- });
235
- }
236
- }
237
-
238
- // Response Construction
239
- if (useOptimizedListing) {
240
- res.json({
241
- success: true,
242
- files: files,
243
- folders: folders,
244
- prefix: effectivePrefix,
245
- search: search,
246
- // In optimized mode, we use token-based pagination, not page numbers
247
- currentPage: 1,
248
- totalPages: 1, // Always 1 since we use continuation tokens instead
249
- hasNextPage: !!nextContinuationToken,
250
- hasPrevPage: false, // Token-based pagination doesn't support backwards navigation
251
- nextContinuationToken: nextContinuationToken,
252
- mode: 'optimized',
253
- totalFiles: -1, // Unknown in optimized mode without full scan
254
- paginationType: 'continuation-token' // Indicate the type of pagination
255
- });
256
-
257
- } else {
258
- // Legacy Pagination Logic (In-Memory)
259
- const totalFiles = files.length;
260
- const totalPages = Math.max(1, Math.ceil(totalFiles / pageSize));
261
- const currentPage = Math.max(1, parseInt(page, 10) || 1);
262
- const startIndex = (currentPage - 1) * pageSize;
263
- const paginatedFiles = files.slice(startIndex, startIndex + pageSize);
264
-
265
- res.json({
266
- success: true,
267
- files: paginatedFiles,
268
- folders: [], // In recursive mode, frontend calculates folders from file paths
269
- prefix: effectivePrefix,
270
- search: search,
271
- currentPage: currentPage,
272
- totalPages,
273
- hasNextPage: currentPage < totalPages,
274
- hasPrevPage: currentPage > 1,
275
- nextPage: currentPage + 1,
276
- prevPage: Math.max(1, currentPage - 1),
277
- totalFiles
278
- });
279
- }
280
-
281
- } catch (error) {
282
- console.error("Error listing files:", error);
283
- res.status(500).json({
284
- success: false,
285
- error: "Error listing files: " + error.message,
286
- files: [],
287
- totalFiles: 0
288
- });
289
- }
290
- });
291
-
292
- // Standard single-file upload
293
- router.post('/mbkbucket/upload', validateSessionAndRole('SuperAdmin'), upload.single('file'), async (req, res) => {
294
- try {
295
- if (!req.file) {
296
- return res.status(400).json({ success: false, error: 'No file selected' });
297
- }
298
-
299
- const { prefix = '' } = req.body;
300
- let effectivePrefix;
301
- try {
302
- effectivePrefix = ensurePrefix(prefix);
303
- } catch (e) {
304
- return res.status(400).json({ success: false, error: e.message });
305
- }
306
-
307
- const key = `${effectivePrefix.replace(/\/+$/, '')}/${req.file.originalname}`;
308
-
309
- const uploadOptions = {
310
- metadata: {
311
- 'original-name': req.file.originalname,
312
- 'upload-source': 'web-portal',
313
- 'user-agent': req.headers['user-agent'] || 'unknown'
314
- },
315
- preventOverwrite: true // Use atomic operation to prevent race conditions
316
- };
317
-
318
- try {
319
- await uploadFile(key, req.file.buffer, req.file.mimetype, uploadOptions);
320
-
321
- // Clean up folder marker if it exists in the parent directory
322
- // This happens when a file is uploaded to a folder that was previously empty
323
- const folderPath = key.substring(0, key.lastIndexOf('/') + 1);
324
- if (folderPath) {
325
- const folderMarkerKey = folderPath; // Already ends with /
326
- try {
327
- const markerExists = await fileExists(folderMarkerKey);
328
- if (markerExists) {
329
- // Check if this is actually a folder marker (zero-byte file)
330
- const metadata = await getFileMetadata(folderMarkerKey);
331
- if (metadata.ContentLength === 0 || metadata.Metadata?.marker === 'true') {
332
- await deleteFile(folderMarkerKey);
333
- console.log(`[mbkbucket] Cleaned up folder marker: ${folderMarkerKey}`);
334
- }
335
- }
336
- } catch (cleanupErr) {
337
- // Non-critical error, just log it
338
- console.warn('[mbkbucket] Failed to clean up folder marker:', cleanupErr.message);
339
- }
340
- }
341
-
342
- res.json({ success: true, message: 'File uploaded successfully', key: key });
343
- } catch (uploadError) {
344
- // Handle file already exists error from atomic operation
345
- if (uploadError.message.includes('already exists')) {
346
- return res.status(409).json({ success: false, error: `File already exists: ${req.file.originalname}` });
347
- }
348
- throw uploadError;
349
- }
350
- } catch (error) {
351
- console.error("Error uploading file:", error);
352
- res.status(500).json({ success: false, error: `Upload failed: ${error.message}` });
353
- }
354
- });
355
-
356
- // S3 Multipart Upload — Step 1: Initiate
357
- // Body: { fileName, prefix?, contentType? }
358
- // Returns: { uploadId, key }
359
- router.post('/mbkbucket/upload-init', validateSessionAndRole('SuperAdmin'), async (req, res) => {
360
- try {
361
- const { fileName, prefix = '', contentType = 'application/octet-stream' } = req.body;
362
- if (!fileName) return res.status(400).json({ success: false, error: 'fileName is required' });
363
-
364
- let effectivePrefix;
365
- try { effectivePrefix = ensurePrefix(prefix); } catch (e) { return res.status(400).json({ success: false, error: e.message }); }
366
- const key = `${effectivePrefix.replace(/\/+$/, '')}/${fileName}`;
367
-
368
- // Check if file exists before initiating multipart upload
369
- // Note: This is still slightly racy, but multipart uploads take time so collision risk is lower
370
- // A complete solution would require server-side locking or unique upload IDs
371
- const exists = await fileExists(key);
372
- if (exists) return res.status(409).json({ success: false, error: `File already exists: ${fileName}` });
373
-
374
- const result = await createMultipartUpload(key, contentType);
375
- return res.json({ success: true, uploadId: result.uploadId, key: result.key });
376
- } catch (err) {
377
- console.error('[mbkbucket] upload-init failed:', err);
378
- return res.status(500).json({ success: false, error: err.message });
379
- }
380
- });
381
-
382
- // S3 Multipart Upload — Step 2: Upload a part
383
- // FormData: chunk (file), partNumber (1-based integer), uploadId, key
384
- // Returns: { partNumber, ETag }
385
- router.post('/mbkbucket/upload-chunk', validateSessionAndRole('SuperAdmin'), upload.single('chunk'), async (req, res) => {
386
- try {
387
- const { uploadId, key, partNumber } = req.body;
388
- if (!req.file || !uploadId || !key || !partNumber) {
389
- return res.status(400).json({ success: false, error: 'chunk file, uploadId, key, and partNumber are required' });
390
- }
391
- const part = parseInt(partNumber, 10);
392
- if (isNaN(part) || part < 1 || part > 10000) {
393
- return res.status(400).json({ success: false, error: 'partNumber must be an integer between 1 and 10000' });
394
- }
395
-
396
- const result = await uploadPart(key, uploadId, part, req.file.buffer);
397
- return res.json({ success: true, partNumber: result.partNumber, ETag: result.ETag });
398
- } catch (err) {
399
- console.error('[mbkbucket] upload-chunk failed:', err);
400
- return res.status(500).json({ success: false, error: err.message });
401
- }
402
- });
403
-
404
- // S3 Multipart Upload — Step 3: Complete
405
- // Body: { uploadId, key, parts: [{ partNumber, ETag }] }
406
- // Returns: { key }
407
- router.post('/mbkbucket/upload-complete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
408
- try {
409
- const { uploadId, key, parts } = req.body;
410
- if (!uploadId || !key || !Array.isArray(parts) || !parts.length) {
411
- return res.status(400).json({ success: false, error: 'uploadId, key, and parts array are required' });
412
- }
413
-
414
- const result = await completeMultipartUpload(key, uploadId, parts);
415
-
416
- // Clean up folder marker if it exists in the parent directory
417
- const folderPath = result.key.substring(0, result.key.lastIndexOf('/') + 1);
418
- if (folderPath) {
419
- const folderMarkerKey = folderPath;
420
- try {
421
- const markerExists = await fileExists(folderMarkerKey);
422
- if (markerExists) {
423
- const metadata = await getFileMetadata(folderMarkerKey);
424
- if (metadata.ContentLength === 0 || metadata.Metadata?.marker === 'true') {
425
- await deleteFile(folderMarkerKey);
426
- console.log(`[mbkbucket] Cleaned up folder marker: ${folderMarkerKey}`);
427
- }
428
- }
429
- } catch (cleanupErr) {
430
- console.warn('[mbkbucket] Failed to clean up folder marker:', cleanupErr.message);
431
- }
432
- }
433
-
434
- return res.json({ success: true, message: 'File uploaded successfully', key: result.key });
435
- } catch (err) {
436
- console.error('[mbkbucket] upload-complete failed:', err);
437
- return res.status(500).json({ success: false, error: err.message });
438
- }
439
- });
440
-
441
- // S3 Multipart Upload — Abort (cleanup on client cancel/error)
442
- // Body: { uploadId, key }
443
- router.post('/mbkbucket/upload-abort', validateSessionAndRole('SuperAdmin'), async (req, res) => {
444
- try {
445
- const { uploadId, key } = req.body;
446
- if (!uploadId || !key) return res.status(400).json({ success: false, error: 'uploadId and key are required' });
447
-
448
- await abortMultipartUpload(key, uploadId);
449
- return res.json({ success: true, message: 'Multipart upload aborted' });
450
- } catch (err) {
451
- console.error('[mbkbucket] upload-abort failed:', err);
452
- return res.status(500).json({ success: false, error: err.message });
453
- }
454
- });
455
-
456
- // Create folder (zero-byte object with trailing slash)
457
- router.post('/mbkbucket/create-folder', validateSessionAndRole('SuperAdmin'), async (req, res) => {
458
- try {
459
- const { prefix = '', folderName } = req.body;
460
- if (!folderName) return res.status(400).json({ success: false, error: 'folderName is required' });
461
-
462
- let effectivePrefix;
463
- try { effectivePrefix = ensurePrefix(prefix); } catch (e) { return res.status(400).json({ success: false, error: e.message }); }
464
-
465
- const folderKey = `${effectivePrefix.replace(/\/+$/, '')}/${folderName}/`;
466
-
467
- // Check if folder marker already exists
468
- const markerExists = await fileExists(folderKey);
469
- if (markerExists) return res.status(409).json({ success: false, error: 'Folder already exists' });
470
-
471
- // Check if any files already exist under this prefix (folder already has content)
472
- const existingFiles = await listfiles(folderKey, { maxKeys: 1 });
473
- const hasContent = existingFiles.Contents && existingFiles.Contents.length > 0;
474
-
475
- if (hasContent) {
476
- // Folder already exists with content, no need to create marker
477
- return res.json({
478
- success: true,
479
- message: 'Folder already exists with content',
480
- key: folderKey,
481
- skipMarker: true
482
- });
483
- }
484
-
485
- // Create zero-byte object to represent empty folder
486
- await uploadFile(folderKey, Buffer.alloc(0), 'application/x-empty', {
487
- metadata: { 'folder': 'true', 'marker': 'true' }
488
- });
489
-
490
- return res.json({ success: true, message: 'Folder created', key: folderKey });
491
- } catch (err) {
492
- console.error('Create folder failed:', err);
493
- return res.status(500).json({ success: false, error: err.message });
494
- }
495
- });
496
-
497
- router.post('/mbkbucket/delete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
498
- try {
499
- const { key, keys, folder } = req.body;
500
-
501
- if (keys && Array.isArray(keys) && keys.length) {
502
- // Bulk delete
503
- await deleteFiles(keys);
504
- return res.json({ success: true, message: 'Files deleted successfully' });
505
- }
506
-
507
- if (!key) return res.status(400).json({ success: false, error: 'Key is required' });
508
-
509
- let keyToDelete;
510
- try { keyToDelete = ensureKeyHasAppPrefix(key); } catch (e) { return res.status(400).json({ success: false, error: e.message }); }
511
-
512
- // If caller indicates it's a folder (or key ends with '/'), delete all objects under the prefix
513
- if (folder === true || String(keyToDelete).endsWith('/')) {
514
- await deleteFolder(keyToDelete);
515
- return res.json({ success: true, message: 'Folder deleted successfully' });
516
- }
517
-
518
- await deleteFile(keyToDelete);
519
- res.json({ success: true, message: 'File deleted successfully' });
520
- } catch (error) {
521
- console.error("Error deleting file:", error);
522
- res.status(500).json({ success: false, error: `Delete failed: ${error.message}` });
523
- }
524
- });
525
-
526
- router.get('/mbkbucket/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
527
- let stream = null;
528
- try {
529
- const key = req.params.key;
530
- let keyToUse;
531
- try {
532
- keyToUse = ensureKeyHasAppPrefix(key);
533
- } catch (e) {
534
- return res.status(400).json({ success: false, error: e.message });
535
- }
536
- const result = await downloadFile(keyToUse);
537
- stream = result.Body;
538
-
539
- res.setHeader('Content-Disposition', `attachment; filename="${keyToUse.split('/').pop()}"`);
540
- res.setHeader('Content-Type', result.ContentType || 'application/octet-stream');
541
-
542
- // Set Content-Length header for proper download progress indication
543
- if (result.ContentLength) {
544
- res.setHeader('Content-Length', result.ContentLength);
545
- }
546
-
547
- // Add cache headers for downloads
548
- res.setHeader('Cache-Control', 'private, max-age=3600');
549
-
550
- // Proper stream error handling
551
- stream.on('error', (err) => {
552
- console.error('Download stream error:', err);
553
- if (!res.headersSent) {
554
- res.status(500).json({ message: 'Error streaming file', error: err.message });
555
- }
556
- if (stream && typeof stream.destroy === 'function') stream.destroy();
557
- });
558
-
559
- // Clean up on client disconnect
560
- req.on('close', () => {
561
- if (stream && typeof stream.destroy === 'function') stream.destroy();
562
- });
563
-
564
- // Handle request timeout
565
- const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
566
- req.setTimeout(timeoutDuration, () => {
567
- console.error(`Download timeout (${timeoutDuration}ms) for: ${keyToUse}`);
568
- if (stream && typeof stream.destroy === 'function') stream.destroy();
569
- });
570
-
571
- stream.pipe(res);
572
- } catch (error) {
573
- console.error("Error downloading file:", error);
574
-
575
- // Clean up stream on error
576
- if (stream && typeof stream.destroy === 'function') stream.destroy();
577
- if (error.message.includes('File not found')) {
578
- res.status(404).json({ message: "File not found", key: req.params.key });
579
- } else if (error.message.includes('Access denied')) {
580
- res.status(403).json({ message: "Access denied" });
581
- } else {
582
- res.status(500).json({ message: "Download failed", error: error.message });
583
- }
584
- }
585
- });
586
-
587
- // New: lightweight player page for robust playback in browsers
588
- router.get('/mbkbucket/player/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
589
- try {
590
- const key = req.params.key;
591
- let keyToUse;
592
- try {
593
- keyToUse = ensureKeyHasAppPrefix(key);
594
- } catch (e) {
595
- return res.status(400).send(e.message);
596
- }
597
- const encoded = encodeURIComponent(keyToUse);
598
- const fileName = keyToUse.split('/').pop();
599
-
600
- const html = `<!doctype html>
601
- <html lang="en">
602
- <head>
603
- <meta charset="utf-8">
604
- <meta name="viewport" content="width=device-width,initial-scale=1">
605
- <title>Preview: ${fileName}</title>
606
- <style>
607
- * { margin: 0; padding: 0; box-sizing: border-box; }
608
- 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; }
609
- .player-container { width: 100%; max-width: 100vw; max-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
610
- video { width: 100%; height: auto; max-width: 100%; max-height: calc(100vh - 40px); object-fit: contain; background: #000; }
611
- .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; }
612
- </style>
613
- </head>
614
- <body>
615
- <div class="file-name">${fileName}</div>
616
- <div class="player-container">
617
- <video controls preload="metadata" src="/mbkbucket/view/${encoded}">
618
- Your browser does not support the video tag.
619
- </video>
620
- </div>
621
- </body>
622
- </html>`;
623
-
624
- res.setHeader('Content-Type', 'text/html; charset=utf-8');
625
- res.setHeader('X-Content-Type-Options', 'nosniff');
626
- return res.status(200).send(html);
627
- } catch (err) {
628
- console.error('Error rendering player page:', err);
629
- return res.status(500).send('Failed to render player');
630
- }
631
- });
632
-
633
- // ---------------------------------------------------------------------------
634
- // Shared constants
635
- // ---------------------------------------------------------------------------
636
- const VIEWABLE_TYPES = new Set([
637
- 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico',
638
- 'mp4', 'webm', 'ogg', 'avi', 'mov',
639
- 'mp3', 'wav', 'flac', 'aac', 'm4a',
640
- 'txt', 'md', 'json', 'xml', 'csv', 'log',
641
- 'js', 'ts', 'html', 'htm', 'css', 'php', 'py', 'java', 'cpp', 'c', 'h',
642
- 'cs', 'rb', 'go', 'rs', 'sql', 'sh', 'bat', 'ps1', 'yaml', 'yml',
643
- 'toml', 'ini', 'conf', 'pdf'
644
- ]);
645
- // Pre-sorted for 415 error responses (avoids re-sorting on every error)
646
- const VIEWABLE_TYPES_SORTED = Array.from(VIEWABLE_TYPES).sort();
647
-
648
- const MIME_TYPES = {
649
- // Images
650
- 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
651
- 'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
652
- 'bmp': 'image/bmp', 'ico': 'image/x-icon',
653
- // Videos
654
- 'mp4': 'video/mp4', 'webm': 'video/webm', 'ogg': 'video/ogg',
655
- 'avi': 'video/x-msvideo', 'mov': 'video/quicktime',
656
- // Audio
657
- 'mp3': 'audio/mpeg', 'wav': 'audio/wav', 'flac': 'audio/flac',
658
- 'aac': 'audio/aac', 'm4a': 'audio/mp4',
659
- // Text / Code
660
- 'txt': 'text/plain; charset=utf-8', 'md': 'text/markdown; charset=utf-8',
661
- 'json': 'application/json; charset=utf-8', 'xml': 'application/xml; charset=utf-8',
662
- 'csv': 'text/csv; charset=utf-8', 'log': 'text/plain; charset=utf-8',
663
- 'js': 'text/javascript; charset=utf-8', 'ts': 'text/typescript; charset=utf-8',
664
- 'html': 'text/html; charset=utf-8', 'htm': 'text/html; charset=utf-8',
665
- 'css': 'text/css; charset=utf-8', 'php': 'text/x-php; charset=utf-8',
666
- 'py': 'text/x-python; charset=utf-8', 'java': 'text/x-java-source; charset=utf-8',
667
- 'cpp': 'text/x-c++src; charset=utf-8', 'c': 'text/x-csrc; charset=utf-8',
668
- 'h': 'text/x-chdr; charset=utf-8', 'cs': 'text/x-csharp; charset=utf-8',
669
- 'rb': 'text/x-ruby; charset=utf-8', 'go': 'text/x-go; charset=utf-8',
670
- 'rs': 'text/x-rust; charset=utf-8', 'sql': 'text/x-sql; charset=utf-8',
671
- 'sh': 'text/x-shellscript; charset=utf-8', 'bat': 'text/x-msdos-batch; charset=utf-8',
672
- 'ps1': 'text/x-powershell; charset=utf-8', 'yaml': 'text/x-yaml; charset=utf-8',
673
- 'yml': 'text/x-yaml; charset=utf-8', 'toml': 'text/x-toml; charset=utf-8',
674
- 'ini': 'text/x-ini; charset=utf-8', 'conf': 'text/x-conf; charset=utf-8',
675
- // Documents
676
- 'pdf': 'application/pdf'
677
- };
678
-
679
- // ---------------------------------------------------------------------------
680
- // Shared inline-serve helper
681
- // Options:
682
- // noindex {boolean} – adds X-Robots-Tag: noindex, nofollow (default false)
683
- // ---------------------------------------------------------------------------
684
- async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
685
- const fileName = keyToUse.split('/').pop();
686
- const fileExtension = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : '';
687
-
688
- console.log(`[mbkbucket] 📥 Request for ${fileName} (type: ${fileExtension}, range: ${req.headers.range || 'none'})`);
689
-
690
- if (!VIEWABLE_TYPES.has(fileExtension)) {
691
- return res.status(415).json({
692
- message: 'File type not supported for viewing',
693
- supportedTypes: VIEWABLE_TYPES_SORTED
694
- });
695
- }
696
-
697
- const ifNoneMatch = req.headers['if-none-match'];
698
- const ifModifiedSince = req.headers['if-modified-since'];
699
- const rangeHeader = req.headers.range;
700
-
701
- const isStaticAsset = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'css', 'js'].includes(fileExtension);
702
- const isVideo = ['mp4', 'webm', 'ogg', 'avi', 'mov'].includes(fileExtension);
703
- const isAudio = ['mp3', 'wav', 'flac', 'aac', 'm4a'].includes(fileExtension);
704
- const isPdf = fileExtension === 'pdf';
705
- const supportsRanges = isVideo || isAudio || isPdf;
706
- const contentType = MIME_TYPES[fileExtension] || 'application/octet-stream';
707
-
708
- // ---------------------------------------------------------------------------
709
- // Fast path for range requests (video / audio / PDF streaming)
710
- // Use a cheap HEAD (getFileMetadata) to get size + ETag, then fetch ONLY the
711
- // requested byte range — avoids downloading the full file first.
712
- // ---------------------------------------------------------------------------
713
- if (supportsRanges && rangeHeader) {
714
- console.log(`[mbkbucket] Processing range request: ${rangeHeader} for ${fileName}`);
715
- let meta;
716
- try {
717
- meta = await getFileMetadata(keyToUse);
718
- } catch (err) {
719
- console.error('[mbkbucket] serveFileInline metadata error:', err);
720
- return res.status(500).json({ message: 'Failed to fetch file metadata', error: err.message });
721
- }
722
-
723
- if (!meta || !meta.exists) {
724
- return res.status(404).json({ message: 'File not found', key: keyToUse });
725
- }
726
-
727
- const etag = `"${meta.LastModified?.getTime() || Date.now()}-${meta.ContentLength || 0}"`;
728
- if (ifNoneMatch === etag) return res.status(304).end();
729
-
730
- const total = Number(meta.ContentLength);
731
- if (!total) {
732
- // No content-length — fall through to full-file path
733
- } else {
734
- const CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB
735
- const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
736
- if (!match) {
737
- res.status(416).setHeader('Content-Range', `bytes */${total}`);
738
- return res.end();
739
- }
740
-
741
- const [, startStr, endStr] = match;
742
- let start, end;
743
-
744
- if (startStr === '') {
745
- const suffix = parseInt(endStr, 10);
746
- if (isNaN(suffix) || suffix <= 0) {
747
- res.status(416).setHeader('Content-Range', `bytes */${total}`);
748
- return res.end();
749
- }
750
- start = Math.max(0, total - suffix);
751
- end = total - 1;
752
- } else {
753
- start = parseInt(startStr, 10);
754
- if (isNaN(start) || start < 0) {
755
- res.status(416).setHeader('Content-Range', `bytes */${total}`);
756
- return res.end();
757
- }
758
- end = endStr === '' ? Math.min(start + CHUNK_SIZE - 1, total - 1) : parseInt(endStr, 10);
759
- if (isNaN(end) || end < start) {
760
- res.status(416).setHeader('Content-Range', `bytes */${total}`);
761
- return res.end();
762
- }
763
- }
764
-
765
- if (start >= total || start > end || end >= total) {
766
- res.status(416).setHeader('Content-Range', `bytes */${total}`);
767
- return res.end();
768
- }
769
-
770
- const chunksize = end - start + 1;
771
-
772
- try {
773
- const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}` });
774
-
775
- if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
776
- console.warn(`Range size mismatch for ${keyToUse}: expected=${chunksize} got=${rangeResult.ContentLength}`);
777
- }
778
-
779
- console.log(`[mbkbucket] ✓ Serving range bytes ${start}-${end}/${total} (${chunksize} bytes) for ${fileName}`);
780
-
781
- res.status(206);
782
- res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
783
- res.setHeader('Content-Length', chunksize);
784
- res.setHeader('Content-Type', contentType);
785
- res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate');
786
- res.setHeader('ETag', etag);
787
- res.setHeader('Accept-Ranges', 'bytes');
788
- if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
789
- res.setHeader('X-Content-Type-Options', 'nosniff');
790
- if (meta.LastModified) res.setHeader('Last-Modified', meta.LastModified.toUTCString());
791
-
792
- // Comprehensive stream error handling and cleanup
793
- const cleanupStream = () => {
794
- if (rangeResult.Body && typeof rangeResult.Body.destroy === 'function') {
795
- rangeResult.Body.removeAllListeners();
796
- rangeResult.Body.destroy();
797
- }
798
- };
799
-
800
- rangeResult.Body.on('error', (err) => {
801
- console.error('Range stream error:', err);
802
- cleanupStream();
803
- if (!res.headersSent) res.status(500).end('Stream error');
804
- });
805
-
806
- req.on('close', cleanupStream);
807
- req.on('aborted', cleanupStream);
808
-
809
- req.setTimeout(120000, () => {
810
- console.error('Range request timeout for file:', keyToUse);
811
- cleanupStream();
812
- if (!res.headersSent) res.status(408).end('Request timeout');
813
- });
814
-
815
- res.on('finish', cleanupStream);
816
- res.on('close', cleanupStream);
817
-
818
- return rangeResult.Body.pipe(res);
819
-
820
- } catch (rangeError) {
821
- console.error('Range request failed:', rangeError);
822
- // fall through to full-file path
823
- }
824
- }
825
- }
826
-
827
- // ---------------------------------------------------------------------------
828
- // Standard path: full-file download (non-media, or media without Range header)
829
- // ---------------------------------------------------------------------------
830
- const downloadOptions = {
831
- ...(ifNoneMatch && { ifNoneMatch }),
832
- ...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) })
833
- };
834
-
835
- if (isPdf) {
836
- console.log(`[mbkbucket] Serving full PDF (no range header) for ${fileName}`);
837
- }
838
-
839
- let result;
840
- try {
841
- result = await downloadFile(keyToUse, downloadOptions);
842
- } catch (err) {
843
- if (err.name === 'NoSuchKey' || err.name === 'NotFound') {
844
- return res.status(404).json({ message: 'File not found', key: keyToUse });
845
- } else if (err.name === 'AccessDenied') {
846
- return res.status(403).json({ message: 'Access denied' });
847
- }
848
- console.error('[mbkbucket] serveFileInline download error:', err);
849
- return res.status(500).json({ message: 'Failed to fetch file', error: err.message });
850
- }
851
-
852
- if (result && result.notModified) return res.status(304).end();
853
-
854
- const etag = `"${result.LastModified?.getTime() || Date.now()}-${result.ContentLength || 0}"`;
855
- if (ifNoneMatch === etag) return res.status(304).end();
856
-
857
- res.setHeader('Content-Type', contentType);
858
- res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
859
- res.setHeader('ETag', etag);
860
- res.setHeader('X-Content-Type-Options', 'nosniff');
861
- res.setHeader('X-Frame-Options', 'SAMEORIGIN');
862
- if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
863
- if (result.ContentLength) res.setHeader('Content-Length', result.ContentLength);
864
- if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
865
- if (fileExtension === 'html' || fileExtension === 'htm') {
866
- res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
867
- }
868
- if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
869
-
870
- if (isStaticAsset) {
871
- res.setHeader('Cache-Control', 'public, max-age=3600, immutable');
872
- } else if (supportsRanges) {
873
- // For video, audio, and PDF - enable caching with revalidation
874
- res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate');
875
- } else {
876
- res.setHeader('Cache-Control', 'public, max-age=300');
877
- }
878
-
879
- res.status(200);
880
-
881
- // Comprehensive stream cleanup to prevent memory leaks
882
- const cleanupStream = () => {
883
- if (result.Body && typeof result.Body.destroy === 'function') {
884
- result.Body.removeAllListeners();
885
- result.Body.destroy();
886
- }
887
- };
888
-
889
- result.Body.on('error', (err) => {
890
- console.error('Stream error:', err);
891
- cleanupStream();
892
- if (!res.headersSent) res.status(500).json({ message: 'Error streaming file', error: err.message });
893
- });
894
-
895
- req.on('close', cleanupStream);
896
- req.on('aborted', cleanupStream);
897
-
898
- const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
899
- req.setTimeout(timeoutDuration, () => {
900
- console.error(`Request timeout (${timeoutDuration}ms) for file: ${keyToUse}, size: ${result.ContentLength}`);
901
- cleanupStream();
902
- if (!res.headersSent) res.status(408).json({ message: 'Request timeout' });
903
- });
904
-
905
- res.on('finish', cleanupStream);
906
- res.on('close', cleanupStream);
907
-
908
- result.Body.pipe(res);
909
- }
910
-
911
- // ---------------------------------------------------------------------------
912
- // Routes
913
- // ---------------------------------------------------------------------------
914
-
915
- router.get('/mbkbucket/view/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
916
- try {
917
- let keyToUse;
918
- try {
919
- keyToUse = ensureKeyHasAppPrefix(req.params.key);
920
- } catch (e) {
921
- return res.status(400).json({ message: e.message });
922
- }
923
-
924
- await serveFileInline(req, res, keyToUse, { noindex: true });
925
-
926
- } catch (error) {
927
- console.error("Error viewing file:", error);
928
- if (error.name === 'NoSuchKey' || error.name === 'NotFound') {
929
- res.status(404).json({ message: "File not found", key: req.params.key });
930
- } else if (error.name === 'AccessDenied') {
931
- res.status(403).json({ message: "Access denied" });
932
- } else {
933
- res.status(500).json({ message: "View failed", error: error.message });
934
- }
935
- }
936
- });
937
-
938
- if (mbkbucketVar?.publiView_enabled) {
939
- router.get('/mbkbucket/p_view/:key(*)', pviewRateLimit, pviewSecurity, async (req, res) => {
940
- try {
941
- const key = req.params.key;
942
-
943
- // No directory indexing — reject folder-like or empty keys
944
- if (!key || key.endsWith('/')) {
945
- return res.status(403).json({ message: 'Directory indexing is not allowed' });
946
- }
947
-
948
- let keyToUse;
949
- try {
950
- keyToUse = ensureKeyHasAppPrefix(key);
951
- } catch (e) {
952
- return res.status(400).json({ message: e.message });
953
- }
954
-
955
- // Determine pview_b: check bucket config first, then env var
956
- let p_view_inline = !!mbkbucketVar?.p_view_inline;
957
-
958
-
959
- if (!p_view_inline) {
960
- // Signed URL mode — redirect to a pre-signed S3 URL (1 hour expiry)
961
- try {
962
- const signed = await generateSignedUrl(keyToUse, 'getObject', 3600);
963
- return res.redirect(302, signed.url);
964
- } catch (err) {
965
- console.error('[mbkbucket] p_view signed URL error:', err);
966
- return res.status(500).json({ message: 'Failed to generate access URL' });
967
- }
968
- }
969
-
970
- // Direct serve mode — stream file inline (with noindex header)
971
- await serveFileInline(req, res, keyToUse, { noindex: true });
972
- } catch (error) {
973
- console.error('[mbkbucket] p_view error:', error);
974
- res.status(500).json({ message: 'Internal server error', error: error.message });
975
- }
976
- });
977
- }
978
-
979
- export default router;