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