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