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.
package/lib/bucket.js DELETED
@@ -1,726 +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 } from "./s3.js";
4
- import multer from "multer";
5
- import path from "path";
6
- import fs from 'fs';
7
- import os from 'os';
8
- import crypto from 'crypto';
9
- import { appVersion, getLatestVersion } from "./config/index.js";
10
-
11
- const router = express.Router();
12
- const upload = multer({ storage: multer.memoryStorage() });
13
-
14
- // Temp folder for chunked uploads
15
- const CHUNKS_DIR = path.join(os.tmpdir(), 'mbkbucket-chunks');
16
- fs.mkdirSync(CHUNKS_DIR, { recursive: true });
17
-
18
- // Key/prefix helpers are provided by `s3.js`: `ensurePrefix`, `ensureKeyHasAppPrefix`, `getAppName`.
19
-
20
- router.get('/mbkbucket', validateSessionAndRole('SuperAdmin'), async (req, res) => {
21
- 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 });
22
- });
23
-
24
- router.get(['/mbkbucket/info', "/mbkbucket/i"], validateSessionAndRole('SuperAdmin'), async (req, res) => {
25
- let latestVersion = 'unknown';
26
- try {
27
- latestVersion = await getLatestVersion();
28
- } catch (err) {
29
- console.error("[mbkauthe] Error fetching package-lock.json:", err);
30
- }
31
- renderPage(req, res, "info.handlebars", false, { page: "MBK Bucket Info", appVersion, latestVersion, APP_NAME: mbkautheVar?.APP_NAME });
32
- });
33
-
34
- // API endpoint for fetching files
35
- router.get('/mbkbucket/api/files', validateSessionAndRole('SuperAdmin'), async (req, res) => {
36
- const { prefix = '', page = '1', search = '', recursive = 'true', token = '' } = req.query;
37
- const pageSize = 100;
38
- let effectivePrefix;
39
- try {
40
- effectivePrefix = ensurePrefix(prefix);
41
- } catch (e) {
42
- return res.status(400).json({ success: false, error: e.message });
43
- }
44
-
45
- // Determine mode: Optimized (Folder View, No Search) vs Legacy/Deep (Flat View or Search)
46
- const isSearch = search && search.trim().length > 0;
47
- // If recursive is explicitly 'false', we respect it. Default to true if missing (though frontend should send it)
48
- // Logic: Optimization works only if NOT recursive and NOT searching.
49
- const isRecursive = recursive === 'true';
50
- const useOptimizedListing = !isSearch && !isRecursive;
51
-
52
- try {
53
- let files = [];
54
- let folders = []; // For CommonPrefixes (subfolders)
55
- let nextContinuationToken = null;
56
- let usedPrefix = effectivePrefix;
57
- let legacyFallback = false;
58
-
59
- if (useOptimizedListing) {
60
- // --- OPTIMIZED PATH: S3 Delimiter (Folder View) ---
61
- // We process only one page from S3. This reduces API calls significantly.
62
-
63
- // FIX: Ensure root listing uses trailing slash to prevent "app-name" matching "app-name-backup"
64
- // and ensure directory browsing works correctly if ensurePrefix didn't append slash (which it now does if present in input).
65
- // But for root, input is empty, ensurePrefix returns "app", so we force "app/" here.
66
- let listPrefix = effectivePrefix;
67
- if (listPrefix === getAppName() && !listPrefix.endsWith('/')) {
68
- listPrefix += '/';
69
- }
70
-
71
- const result = await listfiles(listPrefix, {
72
- continuationToken: token || undefined, // Use token from query if provided
73
- delimiter: '/',
74
- maxKeys: 1000 // Fetch a healthy batch.
75
- });
76
-
77
- files = result.Contents || [];
78
- // S3 returns CommonPrefixes for folders when delimiter is used
79
- folders = (result.CommonPrefixes || []).map(p => p.Prefix).filter(p => p !== effectivePrefix);
80
- nextContinuationToken = result.NextContinuationToken;
81
-
82
- // Note: In optimized mode, totalFiles is unknown without scanning everything.
83
- } else {
84
- // --- LEGACY PATH: Recursive Scan (Search or Flat View) ---
85
- let continuationToken = undefined;
86
-
87
- // Fetch all files recursively (handling S3 pagination)
88
- do {
89
- const result = await listfiles(effectivePrefix, { continuationToken });
90
- if (result.Contents) {
91
- files = files.concat(result.Contents);
92
- }
93
- continuationToken = result.nextToken;
94
- } while (continuationToken);
95
-
96
- // If no files found under the app prefixed path, try root as a legacy fallback
97
- if (!files.length && !search) {
98
- try {
99
- const rootResult = await listfiles('');
100
- if ((rootResult.Contents || []).length) {
101
- files = rootResult.Contents || [];
102
- usedPrefix = '';
103
- legacyFallback = true;
104
- }
105
- } catch (fallbackErr) {
106
- console.warn('Fallback root listing failed:', fallbackErr.message);
107
- }
108
- }
109
-
110
- // Apply search filter if provided
111
- if (isSearch) {
112
- const searchLower = search.toLowerCase();
113
- files = files.filter(file => {
114
- return file.Key.toLowerCase().includes(searchLower);
115
- });
116
- }
117
- }
118
-
119
- // Response Construction
120
- if (useOptimizedListing) {
121
- res.json({
122
- success: true,
123
- files: files,
124
- folders: folders,
125
- prefix: effectivePrefix,
126
- search: search,
127
- // In optimized mode, we don't support random access "Page X". We support "Next".
128
- currentPage: 1,
129
- totalPages: nextContinuationToken ? 9999 : 1,
130
- hasNextPage: !!nextContinuationToken,
131
- nextContinuationToken: nextContinuationToken,
132
- mode: 'optimized',
133
- totalFiles: -1
134
- });
135
-
136
- } else {
137
- // Legacy Pagination Logic (In-Memory)
138
- const totalFiles = files.length;
139
- const totalPages = Math.max(1, Math.ceil(totalFiles / pageSize));
140
- const currentPage = Math.max(1, parseInt(page, 10) || 1);
141
- const startIndex = (currentPage - 1) * pageSize;
142
- const paginatedFiles = files.slice(startIndex, startIndex + pageSize);
143
-
144
- res.json({
145
- success: true,
146
- files: paginatedFiles,
147
- folders: [], // In recursive mode, frontend calculates folders from file paths
148
- prefix: effectivePrefix,
149
- search: search,
150
- currentPage: currentPage,
151
- totalPages,
152
- hasNextPage: currentPage < totalPages,
153
- hasPrevPage: currentPage > 1,
154
- nextPage: currentPage + 1,
155
- prevPage: Math.max(1, currentPage - 1),
156
- totalFiles
157
- });
158
- }
159
-
160
- } catch (error) {
161
- console.error("Error listing files:", error);
162
- res.status(500).json({
163
- success: false,
164
- error: "Error listing files: " + error.message,
165
- files: [],
166
- totalFiles: 0
167
- });
168
- }
169
- });
170
-
171
- // Standard single-file upload
172
- router.post('/mbkbucket/upload', validateSessionAndRole('SuperAdmin'), upload.single('file'), async (req, res) => {
173
- try {
174
- if (!req.file) {
175
- return res.status(400).json({ success: false, error: 'No file selected' });
176
- }
177
-
178
- const { prefix = '' } = req.body;
179
- let effectivePrefix;
180
- try {
181
- effectivePrefix = ensurePrefix(prefix);
182
- } catch (e) {
183
- return res.status(400).json({ success: false, error: e.message });
184
- }
185
-
186
- const key = `${effectivePrefix.replace(/\/+$/, '')}/${req.file.originalname}`;
187
-
188
- // Check if file already exists
189
- const exists = await fileExists(key);
190
- if (exists) {
191
- return res.status(409).json({ success: false, error: `File already exists: ${req.file.originalname}` });
192
- }
193
-
194
- const uploadOptions = {
195
- metadata: {
196
- 'original-name': req.file.originalname,
197
- 'upload-source': 'web-portal',
198
- 'user-agent': req.headers['user-agent'] || 'unknown'
199
- }
200
- };
201
-
202
- await uploadFile(key, req.file.buffer, req.file.mimetype, uploadOptions);
203
- res.json({ success: true, message: 'File uploaded successfully', key: key });
204
- } catch (error) {
205
- console.error("Error uploading file:", error);
206
- res.status(500).json({ success: false, error: `Upload failed: ${error.message}` });
207
- }
208
- });
209
-
210
- // Chunk upload receiver - accepts a single chunk (FormData: chunk file under field 'chunk')
211
- router.post('/mbkbucket/upload-chunk', validateSessionAndRole('SuperAdmin'), upload.single('chunk'), async (req, res) => {
212
- try {
213
- const { uploadId, fileName, chunkIndex, totalChunks } = req.body;
214
- if (!req.file || !fileName || typeof chunkIndex === 'undefined' || typeof totalChunks === 'undefined') {
215
- return res.status(400).json({ success: false, error: 'Missing required parameters' });
216
- }
217
-
218
- const id = uploadId || crypto.randomBytes(12).toString('hex');
219
- const dir = path.join(CHUNKS_DIR, id);
220
- await fs.promises.mkdir(dir, { recursive: true });
221
-
222
- const partName = path.join(dir, `part-${String(chunkIndex).padStart(6, '0')}`);
223
- await fs.promises.writeFile(partName, req.file.buffer);
224
-
225
- return res.json({ success: true, uploadId: id, chunkIndex: Number(chunkIndex) });
226
- } catch (err) {
227
- console.error('Chunk upload failed:', err);
228
- return res.status(500).json({ success: false, error: err.message });
229
- }
230
- });
231
-
232
- // Upload complete - assemble chunks and upload final file
233
- router.post('/mbkbucket/upload-complete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
234
- try {
235
- const { uploadId, fileName, prefix = '', contentType = 'application/octet-stream' } = req.body;
236
- if (!uploadId || !fileName) return res.status(400).json({ success: false, error: 'uploadId and fileName are required' });
237
-
238
- const dir = path.join(CHUNKS_DIR, uploadId);
239
- const files = await fs.promises.readdir(dir);
240
- // Sort by part name
241
- files.sort();
242
-
243
- const buffers = [];
244
- for (const f of files) {
245
- const b = await fs.promises.readFile(path.join(dir, f));
246
- buffers.push(b);
247
- }
248
-
249
- const assembled = Buffer.concat(buffers);
250
-
251
- // Upload to bucket
252
- let effectivePrefix;
253
- try { effectivePrefix = ensurePrefix(prefix); } catch (e) { return res.status(400).json({ success: false, error: e.message }); }
254
- const key = `${effectivePrefix.replace(/\/+$/, '')}/${fileName}`;
255
-
256
- // Check existence
257
- const exists = await fileExists(key);
258
- if (exists) return res.status(409).json({ success: false, error: 'File already exists' });
259
-
260
- await uploadFile(key, assembled, contentType, { metadata: { 'chunked-upload': 'true', 'upload-id': uploadId } });
261
-
262
- // Cleanup chunks
263
- for (const f of files) {
264
- await fs.promises.unlink(path.join(dir, f)).catch(() => { });
265
- }
266
- await fs.promises.rmdir(dir).catch(() => { });
267
-
268
- return res.json({ success: true, message: 'File uploaded successfully (assembled)', key });
269
- } catch (err) {
270
- console.error('Assemble/upload failed:', err);
271
- return res.status(500).json({ success: false, error: err.message });
272
- }
273
- });
274
-
275
- // Create folder (zero-byte object with trailing slash)
276
- router.post('/mbkbucket/create-folder', validateSessionAndRole('SuperAdmin'), async (req, res) => {
277
- try {
278
- const { prefix = '', folderName } = req.body;
279
- if (!folderName) return res.status(400).json({ success: false, error: 'folderName is required' });
280
-
281
- let effectivePrefix;
282
- try { effectivePrefix = ensurePrefix(prefix); } catch (e) { return res.status(400).json({ success: false, error: e.message }); }
283
-
284
- const folderKey = `${effectivePrefix.replace(/\/+$/, '')}/${folderName}/`;
285
-
286
- // Check if exists
287
- const exists = await fileExists(folderKey);
288
- if (exists) return res.status(409).json({ success: false, error: 'Folder already exists' });
289
-
290
- // Create zero-byte object to represent folder
291
- await uploadFile(folderKey, Buffer.alloc(0), 'application/x-empty', { metadata: { 'folder': 'true' } });
292
-
293
- return res.json({ success: true, message: 'Folder created', key: folderKey });
294
- } catch (err) {
295
- console.error('Create folder failed:', err);
296
- return res.status(500).json({ success: false, error: err.message });
297
- }
298
- });
299
-
300
- router.post('/mbkbucket/delete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
301
- try {
302
- const { key, keys, folder } = req.body;
303
-
304
- if (keys && Array.isArray(keys) && keys.length) {
305
- // Bulk delete
306
- await deleteFiles(keys);
307
- return res.json({ success: true, message: 'Files deleted successfully' });
308
- }
309
-
310
- if (!key) return res.status(400).json({ success: false, error: 'Key is required' });
311
-
312
- let keyToDelete;
313
- try { keyToDelete = ensureKeyHasAppPrefix(key); } catch (e) { return res.status(400).json({ success: false, error: e.message }); }
314
-
315
- // If caller indicates it's a folder (or key ends with '/'), delete all objects under the prefix
316
- if (folder === true || String(keyToDelete).endsWith('/')) {
317
- await deleteFolder(keyToDelete);
318
- return res.json({ success: true, message: 'Folder deleted successfully' });
319
- }
320
-
321
- await deleteFile(keyToDelete);
322
- res.json({ success: true, message: 'File deleted successfully' });
323
- } catch (error) {
324
- console.error("Error deleting file:", error);
325
- res.status(500).json({ success: false, error: `Delete failed: ${error.message}` });
326
- }
327
- });
328
-
329
- router.get('/mbkbucket/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
330
- try {
331
- const key = req.params.key;
332
- let keyToUse;
333
- try {
334
- keyToUse = ensureKeyHasAppPrefix(key);
335
- } catch (e) {
336
- return res.status(400).json({ success: false, error: e.message });
337
- }
338
- const result = await downloadFile(keyToUse);
339
-
340
- res.setHeader('Content-Disposition', `attachment; filename="${keyToUse.split('/').pop()}"`);
341
- res.setHeader('Content-Type', result.ContentType || 'application/octet-stream');
342
-
343
- // Set Content-Length header for proper download progress indication
344
- if (result.ContentLength) {
345
- res.setHeader('Content-Length', result.ContentLength);
346
- }
347
-
348
- // Add cache headers for downloads
349
- res.setHeader('Cache-Control', 'private, max-age=3600');
350
-
351
- result.Body.pipe(res);
352
- } catch (error) {
353
- console.error("Error downloading file:", error);
354
-
355
- if (error.message.includes('File not found')) {
356
- res.status(404).json({ message: "File not found", key: req.params.key });
357
- } else if (error.message.includes('Access denied')) {
358
- res.status(403).json({ message: "Access denied" });
359
- } else {
360
- res.status(500).json({ message: "Download failed", error: error.message });
361
- }
362
- }
363
- });
364
-
365
- // New: lightweight player page for robust playback in browsers
366
- router.get('/mbkbucket/player/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
367
- try {
368
- const key = req.params.key;
369
- let keyToUse;
370
- try {
371
- keyToUse = ensureKeyHasAppPrefix(key);
372
- } catch (e) {
373
- return res.status(400).send(e.message);
374
- }
375
- const encoded = encodeURIComponent(keyToUse);
376
- const fileName = keyToUse.split('/').pop();
377
-
378
- const html = `<!doctype html>
379
- <html lang="en">
380
- <head>
381
- <meta charset="utf-8">
382
- <meta name="viewport" content="width=device-width,initial-scale=1">
383
- <title>Preview: ${fileName}</title>
384
- <style>
385
- * { margin: 0; padding: 0; box-sizing: border-box; }
386
- 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; }
387
- .player-container { width: 100%; max-width: 100vw; max-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
388
- video { width: 100%; height: auto; max-width: 100%; max-height: calc(100vh - 40px); object-fit: contain; background: #000; }
389
- .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; }
390
- </style>
391
- </head>
392
- <body>
393
- <div class="file-name">${fileName}</div>
394
- <div class="player-container">
395
- <video controls preload="metadata" src="/mbkbucket/view/${encoded}">
396
- Your browser does not support the video tag.
397
- </video>
398
- </div>
399
- </body>
400
- </html>`;
401
-
402
- res.setHeader('Content-Type', 'text/html; charset=utf-8');
403
- res.setHeader('X-Content-Type-Options', 'nosniff');
404
- return res.status(200).send(html);
405
- } catch (err) {
406
- console.error('Error rendering player page:', err);
407
- return res.status(500).send('Failed to render player');
408
- }
409
- });
410
-
411
- router.get('/mbkbucket/view/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
412
- try {
413
- const key = req.params.key;
414
- let keyToUse;
415
- try {
416
- keyToUse = ensureKeyHasAppPrefix(key);
417
- } catch (e) {
418
- return res.status(400).json({ message: e.message });
419
- }
420
- const fileName = keyToUse.split('/').pop();
421
- const fileExtension = keyToUse.split('.').pop().toLowerCase();
422
-
423
- // Check if file type is viewable before downloading
424
- const viewableTypes = new Set([
425
- 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico',
426
- 'mp4', 'webm', 'ogg', 'avi', 'mov',
427
- 'mp3', 'wav', 'flac', 'aac', 'm4a',
428
- 'txt', 'md', 'json', 'xml', 'csv', 'log',
429
- 'js', 'ts', 'html', 'htm', 'css', 'php', 'py', 'java', 'cpp', 'c', 'h',
430
- 'cs', 'rb', 'go', 'rs', 'sql', 'sh', 'bat', 'ps1', 'yaml', 'yml',
431
- 'toml', 'ini', 'conf', 'pdf'
432
- ]);
433
-
434
- if (!viewableTypes.has(fileExtension)) {
435
- return res.status(415).json({
436
- message: "File type not supported for viewing",
437
- supportedTypes: Array.from(viewableTypes).sort()
438
- });
439
- }
440
-
441
- // Set conditional request headers check
442
- const ifNoneMatch = req.headers['if-none-match'];
443
- const ifModifiedSince = req.headers['if-modified-since'];
444
- const range = req.headers.range;
445
-
446
- // Resolve configured bucket name if possible
447
- let bucket;
448
- try {
449
- bucket = getBucketConfig()?.BUCKET_NAME;
450
- } catch (err) {
451
- console.warn('Could not resolve bucket config for view route:', err.message);
452
- bucket = undefined;
453
- }
454
-
455
- // Download file with conditional headers
456
- const downloadOptions = {
457
- ...(ifNoneMatch && { ifNoneMatch }),
458
- ...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) }),
459
- ...(bucket && { bucketName: bucket })
460
- };
461
-
462
- let result = await downloadFile(keyToUse, downloadOptions);
463
-
464
- // If the underlying storage reported Not Modified, forward 304 to the client
465
- if (result && result.notModified) {
466
- return res.status(304).end();
467
- }
468
-
469
- // Generate ETag based on file metadata
470
- const etag = `"${result.LastModified?.getTime() || Date.now()}-${result.ContentLength || 0}"`;
471
-
472
- // Check if client has cached version
473
- if (ifNoneMatch === etag) {
474
- return res.status(304).end();
475
- }
476
-
477
- // Optimized MIME type mapping
478
- const mimeTypes = {
479
- // Images
480
- 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
481
- 'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
482
- 'bmp': 'image/bmp', 'ico': 'image/x-icon',
483
-
484
- // Videos
485
- 'mp4': 'video/mp4', 'webm': 'video/webm', 'ogg': 'video/ogg',
486
- 'avi': 'video/x-msvideo', 'mov': 'video/quicktime',
487
-
488
- // Audio
489
- 'mp3': 'audio/mpeg', 'wav': 'audio/wav', 'flac': 'audio/flac',
490
- 'aac': 'audio/aac', 'm4a': 'audio/mp4',
491
-
492
- // Text/Code files
493
- 'txt': 'text/plain; charset=utf-8', 'md': 'text/markdown; charset=utf-8',
494
- 'json': 'application/json; charset=utf-8', 'xml': 'application/xml; charset=utf-8',
495
- 'csv': 'text/csv; charset=utf-8', 'log': 'text/plain; charset=utf-8',
496
- 'js': 'text/javascript; charset=utf-8', 'ts': 'text/typescript; charset=utf-8',
497
- 'html': 'text/html; charset=utf-8', 'htm': 'text/html; charset=utf-8',
498
- 'css': 'text/css; charset=utf-8', 'php': 'text/x-php; charset=utf-8',
499
- 'py': 'text/x-python; charset=utf-8', 'java': 'text/x-java-source; charset=utf-8',
500
- 'cpp': 'text/x-c++src; charset=utf-8', 'c': 'text/x-csrc; charset=utf-8',
501
- 'h': 'text/x-chdr; charset=utf-8', 'cs': 'text/x-csharp; charset=utf-8',
502
- 'rb': 'text/x-ruby; charset=utf-8', 'go': 'text/x-go; charset=utf-8',
503
- 'rs': 'text/x-rust; charset=utf-8', 'sql': 'text/x-sql; charset=utf-8',
504
- 'sh': 'text/x-shellscript; charset=utf-8', 'bat': 'text/x-msdos-batch; charset=utf-8',
505
- 'ps1': 'text/x-powershell; charset=utf-8', 'yaml': 'text/x-yaml; charset=utf-8',
506
- 'yml': 'text/x-yaml; charset=utf-8', 'toml': 'text/x-toml; charset=utf-8',
507
- 'ini': 'text/x-ini; charset=utf-8', 'conf': 'text/x-conf; charset=utf-8',
508
-
509
- // Documents
510
- 'pdf': 'application/pdf'
511
- };
512
-
513
- const contentType = mimeTypes[fileExtension] || result.ContentType || 'application/octet-stream';
514
-
515
- // Set optimized headers
516
- res.setHeader('Content-Type', contentType);
517
- res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
518
-
519
- // Performance and caching headers
520
- if (result.ContentLength) {
521
- res.setHeader('Content-Length', result.ContentLength);
522
- }
523
-
524
- // Cache control based on file type
525
- const isStaticAsset = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'css', 'js'].includes(fileExtension);
526
- const isVideo = ['mp4', 'webm', 'ogg', 'avi', 'mov'].includes(fileExtension);
527
- const isAudio = ['mp3', 'wav', 'flac', 'aac', 'm4a'].includes(fileExtension);
528
-
529
- if (isStaticAsset) {
530
- // Cache static assets for 1 hour
531
- res.setHeader('Cache-Control', 'public, max-age=3600, immutable');
532
- } else if (isVideo || isAudio) {
533
- // Cache media files for 24 hours but allow revalidation
534
- res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate');
535
- } else {
536
- // Cache dynamic content for 5 minutes
537
- res.setHeader('Cache-Control', 'public, max-age=300');
538
- }
539
-
540
- res.setHeader('ETag', etag);
541
-
542
- // Set last modified if available
543
- if (result.LastModified) {
544
- res.setHeader('Last-Modified', result.LastModified.toUTCString());
545
- }
546
-
547
- // Security headers
548
- res.setHeader('X-Content-Type-Options', 'nosniff');
549
- res.setHeader('X-Frame-Options', 'SAMEORIGIN');
550
-
551
- // Content Security Policy for better security
552
- if (fileExtension === 'html' || fileExtension === 'htm') {
553
- res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
554
- }
555
-
556
- // Enable range requests for video/audio files
557
- const supportsRanges = ['mp4', 'webm', 'ogg', 'avi', 'mov', 'mp3', 'wav', 'flac', 'aac', 'm4a'].includes(fileExtension);
558
- if (supportsRanges) {
559
- res.setHeader('Accept-Ranges', 'bytes');
560
-
561
- // Handle range requests for video streaming
562
- const rangeHeader = req.headers.range;
563
- if (rangeHeader && result.ContentLength) {
564
- // Support a larger default chunk for better streaming performance
565
- const CHUNK_SIZE = 8 * 1024 * 1024; // 8MB
566
-
567
- // Parse range: support forms 'bytes=start-end', 'bytes=start-', and 'bytes=-suffix'
568
- const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
569
- if (!match) {
570
- res.status(416).setHeader('Content-Range', `bytes */${result.ContentLength}`);
571
- return res.end();
572
- }
573
-
574
- let startStr = match[1];
575
- let endStr = match[2];
576
- let start, end;
577
- const total = Number(result.ContentLength);
578
-
579
- if (startStr === '') {
580
- // Suffix bytes: '-500' means the last 500 bytes
581
- const suffix = parseInt(endStr, 10);
582
- if (isNaN(suffix) || suffix <= 0) {
583
- res.status(416).setHeader('Content-Range', `bytes */${total}`);
584
- return res.end();
585
- }
586
- start = Math.max(0, total - suffix);
587
- end = total - 1;
588
- } else {
589
- start = parseInt(startStr, 10);
590
- if (isNaN(start) || start < 0) {
591
- res.status(416).setHeader('Content-Range', `bytes */${total}`);
592
- return res.end();
593
- }
594
- if (endStr === '') {
595
- end = Math.min(start + CHUNK_SIZE - 1, total - 1);
596
- } else {
597
- end = parseInt(endStr, 10);
598
- if (isNaN(end) || end < start) {
599
- res.status(416).setHeader('Content-Range', `bytes */${total}`);
600
- return res.end();
601
- }
602
- }
603
- }
604
-
605
- // Validate range bounds
606
- if (start >= total || start > end || end >= total) {
607
- res.status(416).setHeader('Content-Range', `bytes */${total}`);
608
- return res.end();
609
- }
610
-
611
- const chunksize = (end - start) + 1;
612
-
613
- try {
614
- console.log(`[mbkbucket] Handling range request for ${keyToUse}: ${start}-${end} (total ${total})`);
615
-
616
- // Get the specific byte range from S3
617
- const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, ...(bucket && { bucketName: bucket }) });
618
-
619
- // Diagnostics: log returned content length and available result keys
620
- console.log(`[mbkbucket] Range downloaded: ${start}-${end} for ${keyToUse}. rangeResult.ContentLength=${rangeResult.ContentLength}, keys=${Object.keys(rangeResult).join(',')}`);
621
-
622
- // Check for mismatches between expected chunk size and returned ContentLength
623
- if (typeof rangeResult.ContentLength === 'number') {
624
- if (rangeResult.ContentLength !== chunksize) {
625
- console.warn(`Range size mismatch for ${keyToUse}: expected=${chunksize} got=${rangeResult.ContentLength} for ${start}-${end}`);
626
- }
627
- } else {
628
- console.warn(`Missing range ContentLength for ${keyToUse} ${start}-${end}`);
629
- }
630
-
631
- res.status(206);
632
- res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
633
- res.setHeader('Content-Length', chunksize);
634
- res.setHeader('Content-Type', contentType);
635
- res.setHeader('Cache-Control', 'public, max-age=3600');
636
- res.setHeader('ETag', etag);
637
-
638
- // Handle stream errors for range requests
639
- rangeResult.Body.on('error', (error) => {
640
- console.error("Range stream error:", error);
641
- if (!res.headersSent) {
642
- res.status(500).end('Stream error');
643
- }
644
- });
645
-
646
- // Log stream end/close
647
- rangeResult.Body.on('end', () => {
648
- console.log(`[mbkbucket] Range stream ended for ${keyToUse}: ${start}-${end}`);
649
- });
650
- rangeResult.Body.on('close', () => {
651
- console.log(`[mbkbucket] Range stream closed for ${keyToUse}: ${start}-${end}`);
652
- });
653
-
654
- // Handle client disconnect
655
- req.on('close', () => {
656
- console.log('[mbkbucket] Client closed connection during range request for', keyToUse, start, end);
657
- if (rangeResult.Body && typeof rangeResult.Body.destroy === 'function') {
658
- rangeResult.Body.destroy();
659
- }
660
- });
661
-
662
- // Set timeout for range requests (longer timeout to allow streaming)
663
- req.setTimeout(120000, () => {
664
- console.error("Range request timeout for file:", keyToUse);
665
- if (rangeResult.Body && typeof rangeResult.Body.destroy === 'function') {
666
- rangeResult.Body.destroy();
667
- }
668
- });
669
-
670
- return rangeResult.Body.pipe(res);
671
-
672
- } catch (rangeError) {
673
- console.error("Range request failed:", rangeError);
674
- // Fallback to full file if range request fails
675
- res.setHeader('Accept-Ranges', 'none');
676
- }
677
- }
678
- }
679
-
680
- // Set appropriate status code
681
- res.status(200);
682
-
683
- // Pipe the response with error handling
684
- result.Body.on('error', (error) => {
685
- console.error("Stream error:", error);
686
- if (!res.headersSent) {
687
- res.status(500).json({ message: "Error streaming file", error: error.message });
688
- }
689
- });
690
-
691
- // Handle client disconnect
692
- req.on('close', () => {
693
- if (result.Body && typeof result.Body.destroy === 'function') {
694
- result.Body.destroy();
695
- }
696
- });
697
-
698
- // Set timeout for large files based on file size
699
- const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000; // 5 minutes for files > 50MB, 2 minutes otherwise
700
- req.setTimeout(timeoutDuration, () => {
701
- console.error(`Request timeout (${timeoutDuration}ms) for file:`, key, `Size: ${result.ContentLength} bytes`);
702
- if (!res.headersSent) {
703
- res.status(408).json({ message: "Request timeout" });
704
- }
705
- if (result.Body && typeof result.Body.destroy === 'function') {
706
- result.Body.destroy();
707
- }
708
- });
709
-
710
- result.Body.pipe(res);
711
-
712
- } catch (error) {
713
- console.error("Error viewing file:", error);
714
-
715
- // Return appropriate error based on error type
716
- if (error.name === 'NoSuchKey' || error.name === 'NotFound') {
717
- res.status(404).json({ message: "File not found", key: req.params.key });
718
- } else if (error.name === 'AccessDenied') {
719
- res.status(403).json({ message: "Access denied" });
720
- } else {
721
- res.status(500).json({ message: "View failed", error: error.message });
722
- }
723
- }
724
- });
725
-
726
- export default router;