mbkbucket 1.3.0 → 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/.env.example +5 -2
- package/README.md +59 -14
- package/index.d.ts +67 -3
- package/index.js +4 -79
- package/lib/bucket.js +658 -405
- package/lib/config/index.js +134 -1
- package/lib/s3.js +297 -54
- package/package.json +2 -1
- package/public/bucketadmin.js +139 -33
- package/views/bucketadmincontent.handlebars +3 -2
- package/views/mbkbucket_info.handlebars +226 -0
- package/views/info.handlebars +0 -1056
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
|
|
6
|
-
import
|
|
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,7 +79,71 @@ 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, "
|
|
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
|
|
@@ -46,7 +161,7 @@ router.get('/mbkbucket/api/files', validateSessionAndRole('SuperAdmin'), async (
|
|
|
46
161
|
const isSearch = search && search.trim().length > 0;
|
|
47
162
|
// If recursive is explicitly 'false', we respect it. Default to true if missing (though frontend should send it)
|
|
48
163
|
// Logic: Optimization works only if NOT recursive and NOT searching.
|
|
49
|
-
const isRecursive = recursive === 'true';
|
|
164
|
+
const isRecursive = recursive === 'true';
|
|
50
165
|
const useOptimizedListing = !isSearch && !isRecursive;
|
|
51
166
|
|
|
52
167
|
try {
|
|
@@ -59,7 +174,7 @@ router.get('/mbkbucket/api/files', validateSessionAndRole('SuperAdmin'), async (
|
|
|
59
174
|
if (useOptimizedListing) {
|
|
60
175
|
// --- OPTIMIZED PATH: S3 Delimiter (Folder View) ---
|
|
61
176
|
// We process only one page from S3. This reduces API calls significantly.
|
|
62
|
-
|
|
177
|
+
|
|
63
178
|
// FIX: Ensure root listing uses trailing slash to prevent "app-name" matching "app-name-backup"
|
|
64
179
|
// and ensure directory browsing works correctly if ensurePrefix didn't append slash (which it now does if present in input).
|
|
65
180
|
// But for root, input is empty, ensurePrefix returns "app", so we force "app/" here.
|
|
@@ -68,15 +183,17 @@ router.get('/mbkbucket/api/files', validateSessionAndRole('SuperAdmin'), async (
|
|
|
68
183
|
listPrefix += '/';
|
|
69
184
|
}
|
|
70
185
|
|
|
71
|
-
const result = await listfiles(listPrefix, {
|
|
186
|
+
const result = await listfiles(listPrefix, {
|
|
72
187
|
continuationToken: token || undefined, // Use token from query if provided
|
|
73
|
-
delimiter: '/',
|
|
188
|
+
delimiter: '/',
|
|
74
189
|
maxKeys: 1000 // Fetch a healthy batch.
|
|
75
190
|
});
|
|
76
|
-
|
|
191
|
+
|
|
77
192
|
files = result.Contents || [];
|
|
193
|
+
// Filter out folder marker objects (zero-byte files ending with /)
|
|
194
|
+
files = files.filter(f => !f.Key.endsWith('/'));
|
|
78
195
|
// S3 returns CommonPrefixes for folders when delimiter is used
|
|
79
|
-
folders = (result.CommonPrefixes || []).map(p => p.Prefix).filter(p => p !== effectivePrefix);
|
|
196
|
+
folders = (result.CommonPrefixes || []).map(p => p.Prefix).filter(p => p !== effectivePrefix);
|
|
80
197
|
nextContinuationToken = result.NextContinuationToken;
|
|
81
198
|
|
|
82
199
|
// Note: In optimized mode, totalFiles is unknown without scanning everything.
|
|
@@ -88,7 +205,9 @@ router.get('/mbkbucket/api/files', validateSessionAndRole('SuperAdmin'), async (
|
|
|
88
205
|
do {
|
|
89
206
|
const result = await listfiles(effectivePrefix, { continuationToken });
|
|
90
207
|
if (result.Contents) {
|
|
91
|
-
files
|
|
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);
|
|
92
211
|
}
|
|
93
212
|
continuationToken = result.nextToken;
|
|
94
213
|
} while (continuationToken);
|
|
@@ -106,7 +225,7 @@ router.get('/mbkbucket/api/files', validateSessionAndRole('SuperAdmin'), async (
|
|
|
106
225
|
console.warn('Fallback root listing failed:', fallbackErr.message);
|
|
107
226
|
}
|
|
108
227
|
}
|
|
109
|
-
|
|
228
|
+
|
|
110
229
|
// Apply search filter if provided
|
|
111
230
|
if (isSearch) {
|
|
112
231
|
const searchLower = search.toLowerCase();
|
|
@@ -118,43 +237,45 @@ router.get('/mbkbucket/api/files', validateSessionAndRole('SuperAdmin'), async (
|
|
|
118
237
|
|
|
119
238
|
// Response Construction
|
|
120
239
|
if (useOptimizedListing) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
240
|
+
res.json({
|
|
241
|
+
success: true,
|
|
242
|
+
files: files,
|
|
243
|
+
folders: folders,
|
|
244
|
+
prefix: effectivePrefix,
|
|
245
|
+
search: search,
|
|
246
|
+
// In optimized mode, we use token-based pagination, not page numbers
|
|
247
|
+
currentPage: 1,
|
|
248
|
+
totalPages: 1, // Always 1 since we use continuation tokens instead
|
|
249
|
+
hasNextPage: !!nextContinuationToken,
|
|
250
|
+
hasPrevPage: false, // Token-based pagination doesn't support backwards navigation
|
|
251
|
+
nextContinuationToken: nextContinuationToken,
|
|
252
|
+
mode: 'optimized',
|
|
253
|
+
totalFiles: -1, // Unknown in optimized mode without full scan
|
|
254
|
+
paginationType: 'continuation-token' // Indicate the type of pagination
|
|
255
|
+
});
|
|
256
|
+
|
|
136
257
|
} else {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
});
|
|
158
279
|
}
|
|
159
280
|
|
|
160
281
|
} catch (error) {
|
|
@@ -185,89 +306,149 @@ router.post('/mbkbucket/upload', validateSessionAndRole('SuperAdmin'), upload.si
|
|
|
185
306
|
|
|
186
307
|
const key = `${effectivePrefix.replace(/\/+$/, '')}/${req.file.originalname}`;
|
|
187
308
|
|
|
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
309
|
const uploadOptions = {
|
|
195
310
|
metadata: {
|
|
196
311
|
'original-name': req.file.originalname,
|
|
197
312
|
'upload-source': 'web-portal',
|
|
198
313
|
'user-agent': req.headers['user-agent'] || 'unknown'
|
|
199
|
-
}
|
|
314
|
+
},
|
|
315
|
+
preventOverwrite: true // Use atomic operation to prevent race conditions
|
|
200
316
|
};
|
|
201
317
|
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
}
|
|
204
350
|
} catch (error) {
|
|
205
351
|
console.error("Error uploading file:", error);
|
|
206
352
|
res.status(500).json({ success: false, error: `Upload failed: ${error.message}` });
|
|
207
353
|
}
|
|
208
354
|
});
|
|
209
355
|
|
|
210
|
-
//
|
|
211
|
-
|
|
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) => {
|
|
212
360
|
try {
|
|
213
|
-
const {
|
|
214
|
-
if (!
|
|
215
|
-
return res.status(400).json({ success: false, error: 'Missing required parameters' });
|
|
216
|
-
}
|
|
361
|
+
const { fileName, prefix = '', contentType = 'application/octet-stream' } = req.body;
|
|
362
|
+
if (!fileName) return res.status(400).json({ success: false, error: 'fileName is required' });
|
|
217
363
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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}`;
|
|
221
367
|
|
|
222
|
-
|
|
223
|
-
|
|
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}` });
|
|
224
373
|
|
|
225
|
-
|
|
374
|
+
const result = await createMultipartUpload(key, contentType);
|
|
375
|
+
return res.json({ success: true, uploadId: result.uploadId, key: result.key });
|
|
226
376
|
} catch (err) {
|
|
227
|
-
console.error('
|
|
377
|
+
console.error('[mbkbucket] upload-init failed:', err);
|
|
228
378
|
return res.status(500).json({ success: false, error: err.message });
|
|
229
379
|
}
|
|
230
380
|
});
|
|
231
381
|
|
|
232
|
-
//
|
|
233
|
-
|
|
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) => {
|
|
234
386
|
try {
|
|
235
|
-
const { uploadId,
|
|
236
|
-
if (!
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
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);
|
|
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' });
|
|
247
394
|
}
|
|
248
395
|
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
// Check existence
|
|
257
|
-
const exists = await fileExists(key);
|
|
258
|
-
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
|
+
});
|
|
259
403
|
|
|
260
|
-
|
|
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
|
+
}
|
|
261
413
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
+
}
|
|
265
432
|
}
|
|
266
|
-
|
|
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
|
+
});
|
|
267
440
|
|
|
268
|
-
|
|
441
|
+
// S3 Multipart Upload — Abort (cleanup on client cancel/error)
|
|
442
|
+
// Body: { uploadId, key }
|
|
443
|
+
router.post('/mbkbucket/upload-abort', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
444
|
+
try {
|
|
445
|
+
const { uploadId, key } = req.body;
|
|
446
|
+
if (!uploadId || !key) return res.status(400).json({ success: false, error: 'uploadId and key are required' });
|
|
447
|
+
|
|
448
|
+
await abortMultipartUpload(key, uploadId);
|
|
449
|
+
return res.json({ success: true, message: 'Multipart upload aborted' });
|
|
269
450
|
} catch (err) {
|
|
270
|
-
console.error('
|
|
451
|
+
console.error('[mbkbucket] upload-abort failed:', err);
|
|
271
452
|
return res.status(500).json({ success: false, error: err.message });
|
|
272
453
|
}
|
|
273
454
|
});
|
|
@@ -283,12 +464,28 @@ router.post('/mbkbucket/create-folder', validateSessionAndRole('SuperAdmin'), as
|
|
|
283
464
|
|
|
284
465
|
const folderKey = `${effectivePrefix.replace(/\/+$/, '')}/${folderName}/`;
|
|
285
466
|
|
|
286
|
-
// Check if exists
|
|
287
|
-
const
|
|
288
|
-
if (
|
|
467
|
+
// Check if folder marker already exists
|
|
468
|
+
const markerExists = await fileExists(folderKey);
|
|
469
|
+
if (markerExists) return res.status(409).json({ success: false, error: 'Folder already exists' });
|
|
470
|
+
|
|
471
|
+
// Check if any files already exist under this prefix (folder already has content)
|
|
472
|
+
const existingFiles = await listfiles(folderKey, { maxKeys: 1 });
|
|
473
|
+
const hasContent = existingFiles.Contents && existingFiles.Contents.length > 0;
|
|
474
|
+
|
|
475
|
+
if (hasContent) {
|
|
476
|
+
// Folder already exists with content, no need to create marker
|
|
477
|
+
return res.json({
|
|
478
|
+
success: true,
|
|
479
|
+
message: 'Folder already exists with content',
|
|
480
|
+
key: folderKey,
|
|
481
|
+
skipMarker: true
|
|
482
|
+
});
|
|
483
|
+
}
|
|
289
484
|
|
|
290
|
-
// Create zero-byte object to represent folder
|
|
291
|
-
await uploadFile(folderKey, Buffer.alloc(0), 'application/x-empty', {
|
|
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
|
+
});
|
|
292
489
|
|
|
293
490
|
return res.json({ success: true, message: 'Folder created', key: folderKey });
|
|
294
491
|
} catch (err) {
|
|
@@ -327,6 +524,7 @@ router.post('/mbkbucket/delete', validateSessionAndRole('SuperAdmin'), async (re
|
|
|
327
524
|
});
|
|
328
525
|
|
|
329
526
|
router.get('/mbkbucket/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
527
|
+
let stream = null;
|
|
330
528
|
try {
|
|
331
529
|
const key = req.params.key;
|
|
332
530
|
let keyToUse;
|
|
@@ -336,8 +534,9 @@ router.get('/mbkbucket/download/:key(*)', validateSessionAndRole('SuperAdmin'),
|
|
|
336
534
|
return res.status(400).json({ success: false, error: e.message });
|
|
337
535
|
}
|
|
338
536
|
const result = await downloadFile(keyToUse);
|
|
537
|
+
stream = result.Body;
|
|
339
538
|
|
|
340
|
-
res.setHeader('Content-Disposition', `attachment; filename="${keyToUse.split('/').pop()}"`);
|
|
539
|
+
res.setHeader('Content-Disposition', `attachment; filename="${keyToUse.split('/').pop()}"`);
|
|
341
540
|
res.setHeader('Content-Type', result.ContentType || 'application/octet-stream');
|
|
342
541
|
|
|
343
542
|
// Set Content-Length header for proper download progress indication
|
|
@@ -348,10 +547,33 @@ router.get('/mbkbucket/download/:key(*)', validateSessionAndRole('SuperAdmin'),
|
|
|
348
547
|
// Add cache headers for downloads
|
|
349
548
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
|
350
549
|
|
|
351
|
-
|
|
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);
|
|
352
572
|
} catch (error) {
|
|
353
573
|
console.error("Error downloading file:", error);
|
|
354
|
-
|
|
574
|
+
|
|
575
|
+
// Clean up stream on error
|
|
576
|
+
if (stream && typeof stream.destroy === 'function') stream.destroy();
|
|
355
577
|
if (error.message.includes('File not found')) {
|
|
356
578
|
res.status(404).json({ message: "File not found", key: req.params.key });
|
|
357
579
|
} else if (error.message.includes('Access denied')) {
|
|
@@ -376,28 +598,28 @@ router.get('/mbkbucket/player/:key(*)', validateSessionAndRole('SuperAdmin'), as
|
|
|
376
598
|
const fileName = keyToUse.split('/').pop();
|
|
377
599
|
|
|
378
600
|
const html = `<!doctype html>
|
|
379
|
-
<html lang="en">
|
|
380
|
-
<head>
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
</head>
|
|
392
|
-
<body>
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
</body>
|
|
400
|
-
</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>`;
|
|
401
623
|
|
|
402
624
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
403
625
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
@@ -408,311 +630,301 @@ router.get('/mbkbucket/player/:key(*)', validateSessionAndRole('SuperAdmin'), as
|
|
|
408
630
|
}
|
|
409
631
|
});
|
|
410
632
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
+
}
|
|
445
696
|
|
|
446
|
-
|
|
447
|
-
|
|
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;
|
|
448
716
|
try {
|
|
449
|
-
|
|
717
|
+
meta = await getFileMetadata(keyToUse);
|
|
450
718
|
} catch (err) {
|
|
451
|
-
console.
|
|
452
|
-
|
|
719
|
+
console.error('[mbkbucket] serveFileInline metadata error:', err);
|
|
720
|
+
return res.status(500).json({ message: 'Failed to fetch file metadata', error: err.message });
|
|
453
721
|
}
|
|
454
722
|
|
|
455
|
-
|
|
456
|
-
|
|
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();
|
|
723
|
+
if (!meta || !meta.exists) {
|
|
724
|
+
return res.status(404).json({ message: 'File not found', key: keyToUse });
|
|
475
725
|
}
|
|
476
726
|
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
}
|
|
727
|
+
const etag = `"${meta.LastModified?.getTime() || Date.now()}-${meta.ContentLength || 0}"`;
|
|
728
|
+
if (ifNoneMatch === etag) return res.status(304).end();
|
|
523
729
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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');
|
|
730
|
+
const total = Number(meta.ContentLength);
|
|
731
|
+
if (!total) {
|
|
732
|
+
// No content-length — fall through to full-file path
|
|
535
733
|
} else {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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');
|
|
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
|
+
}
|
|
550
740
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
|
|
554
|
-
}
|
|
741
|
+
const [, startStr, endStr] = match;
|
|
742
|
+
let start, end;
|
|
555
743
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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}`);
|
|
744
|
+
if (startStr === '') {
|
|
745
|
+
const suffix = parseInt(endStr, 10);
|
|
746
|
+
if (isNaN(suffix) || suffix <= 0) {
|
|
747
|
+
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
571
748
|
return res.end();
|
|
572
749
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
}
|
|
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();
|
|
603
757
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
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) {
|
|
607
760
|
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
608
761
|
return res.end();
|
|
609
762
|
}
|
|
763
|
+
}
|
|
610
764
|
|
|
611
|
-
|
|
765
|
+
if (start >= total || start > end || end >= total) {
|
|
766
|
+
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
767
|
+
return res.end();
|
|
768
|
+
}
|
|
612
769
|
|
|
613
|
-
|
|
614
|
-
console.log(`[mbkbucket] Handling range request for ${keyToUse}: ${start}-${end} (total ${total})`);
|
|
770
|
+
const chunksize = end - start + 1;
|
|
615
771
|
|
|
616
|
-
|
|
617
|
-
|
|
772
|
+
try {
|
|
773
|
+
const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}` });
|
|
618
774
|
|
|
619
|
-
|
|
620
|
-
console.
|
|
775
|
+
if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
|
|
776
|
+
console.warn(`Range size mismatch for ${keyToUse}: expected=${chunksize} got=${rangeResult.ContentLength}`);
|
|
777
|
+
}
|
|
621
778
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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();
|
|
629
797
|
}
|
|
798
|
+
};
|
|
630
799
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
res.
|
|
635
|
-
|
|
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
|
-
});
|
|
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
|
+
});
|
|
661
805
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
+
});
|
|
669
814
|
|
|
670
|
-
|
|
815
|
+
res.on('finish', cleanupStream);
|
|
816
|
+
res.on('close', cleanupStream);
|
|
671
817
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
818
|
+
return rangeResult.Body.pipe(res);
|
|
819
|
+
|
|
820
|
+
} catch (rangeError) {
|
|
821
|
+
console.error('Range request failed:', rangeError);
|
|
822
|
+
// fall through to full-file path
|
|
677
823
|
}
|
|
678
824
|
}
|
|
825
|
+
}
|
|
679
826
|
|
|
680
|
-
|
|
681
|
-
|
|
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
|
+
};
|
|
682
834
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
if (!res.headersSent) {
|
|
687
|
-
res.status(500).json({ message: "Error streaming file", error: error.message });
|
|
688
|
-
}
|
|
689
|
-
});
|
|
835
|
+
if (isPdf) {
|
|
836
|
+
console.log(`[mbkbucket] Serving full PDF (no range header) for ${fileName}`);
|
|
837
|
+
}
|
|
690
838
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
+
}
|
|
697
851
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
852
|
+
if (result && result.notModified) return res.status(304).end();
|
|
853
|
+
|
|
854
|
+
const etag = `"${result.LastModified?.getTime() || Date.now()}-${result.ContentLength || 0}"`;
|
|
855
|
+
if (ifNoneMatch === etag) return res.status(304).end();
|
|
856
|
+
|
|
857
|
+
res.setHeader('Content-Type', contentType);
|
|
858
|
+
res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
|
|
859
|
+
res.setHeader('ETag', etag);
|
|
860
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
861
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
862
|
+
if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
|
|
863
|
+
if (result.ContentLength) res.setHeader('Content-Length', result.ContentLength);
|
|
864
|
+
if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
|
|
865
|
+
if (fileExtension === 'html' || fileExtension === 'htm') {
|
|
866
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
|
|
867
|
+
}
|
|
868
|
+
if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
|
|
869
|
+
|
|
870
|
+
if (isStaticAsset) {
|
|
871
|
+
res.setHeader('Cache-Control', 'public, max-age=3600, immutable');
|
|
872
|
+
} else if (supportsRanges) {
|
|
873
|
+
// For video, audio, and PDF - enable caching with revalidation
|
|
874
|
+
res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate');
|
|
875
|
+
} else {
|
|
876
|
+
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
res.status(200);
|
|
880
|
+
|
|
881
|
+
// Comprehensive stream cleanup to prevent memory leaks
|
|
882
|
+
const cleanupStream = () => {
|
|
883
|
+
if (result.Body && typeof result.Body.destroy === 'function') {
|
|
884
|
+
result.Body.removeAllListeners();
|
|
885
|
+
result.Body.destroy();
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
result.Body.on('error', (err) => {
|
|
890
|
+
console.error('Stream error:', err);
|
|
891
|
+
cleanupStream();
|
|
892
|
+
if (!res.headersSent) res.status(500).json({ message: 'Error streaming file', error: err.message });
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
req.on('close', cleanupStream);
|
|
896
|
+
req.on('aborted', cleanupStream);
|
|
709
897
|
|
|
710
|
-
|
|
898
|
+
const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
|
|
899
|
+
req.setTimeout(timeoutDuration, () => {
|
|
900
|
+
console.error(`Request timeout (${timeoutDuration}ms) for file: ${keyToUse}, size: ${result.ContentLength}`);
|
|
901
|
+
cleanupStream();
|
|
902
|
+
if (!res.headersSent) res.status(408).json({ message: 'Request timeout' });
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
res.on('finish', cleanupStream);
|
|
906
|
+
res.on('close', cleanupStream);
|
|
907
|
+
|
|
908
|
+
result.Body.pipe(res);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ---------------------------------------------------------------------------
|
|
912
|
+
// Routes
|
|
913
|
+
// ---------------------------------------------------------------------------
|
|
914
|
+
|
|
915
|
+
router.get('/mbkbucket/view/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
916
|
+
try {
|
|
917
|
+
let keyToUse;
|
|
918
|
+
try {
|
|
919
|
+
keyToUse = ensureKeyHasAppPrefix(req.params.key);
|
|
920
|
+
} catch (e) {
|
|
921
|
+
return res.status(400).json({ message: e.message });
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
await serveFileInline(req, res, keyToUse, { noindex: true });
|
|
711
925
|
|
|
712
926
|
} catch (error) {
|
|
713
927
|
console.error("Error viewing file:", error);
|
|
714
|
-
|
|
715
|
-
// Return appropriate error based on error type
|
|
716
928
|
if (error.name === 'NoSuchKey' || error.name === 'NotFound') {
|
|
717
929
|
res.status(404).json({ message: "File not found", key: req.params.key });
|
|
718
930
|
} else if (error.name === 'AccessDenied') {
|
|
@@ -723,4 +935,45 @@ router.get('/mbkbucket/view/:key(*)', validateSessionAndRole('SuperAdmin'), asyn
|
|
|
723
935
|
}
|
|
724
936
|
});
|
|
725
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
|
+
|
|
726
979
|
export default router;
|