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