mbkbucket 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +6 -3
- package/.github/PACKAGE.md +0 -4
- package/README.md +59 -14
- package/index.d.ts +192 -39
- package/index.js +4 -79
- package/lib/config/index.js +134 -1
- package/lib/index.js +2 -2
- package/lib/routes/api.js +501 -0
- package/lib/routes/index.js +17 -0
- package/lib/routes/info.js +28 -0
- package/lib/routes/view.js +458 -0
- package/lib/s3.js +297 -54
- package/package.json +3 -2
- package/public/bucketadmin.css +144 -32
- package/public/bucketadmin.js +253 -67
- package/views/bucket.handlebars +3 -3
- package/views/bucketadmin_alerts.handlebars +15 -0
- package/views/bucketadmin_delete_modal.handlebars +40 -0
- package/views/bucketadmin_filelist_skeleton.handlebars +20 -0
- package/views/bucketadmin_preview_modal.handlebars +33 -0
- package/views/bucketadmincontent.handlebars +7 -95
- package/views/mbkbucket_info.handlebars +226 -0
- package/lib/bucket.js +0 -726
- package/views/info.handlebars +0 -1056
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { renderPage, mbkautheVar } from "mbkauthe";
|
|
3
|
+
import { packageJson, getLatestVersion } from "../config/index.js";
|
|
4
|
+
|
|
5
|
+
const router = express.Router();
|
|
6
|
+
|
|
7
|
+
router.get(['/info', "/i"], async (req, res) => {
|
|
8
|
+
let latestVersion = 'unknown';
|
|
9
|
+
try {
|
|
10
|
+
latestVersion = await getLatestVersion();
|
|
11
|
+
} catch (err) {
|
|
12
|
+
console.error("[mbkauthe] Error fetching package-lock.json:", err);
|
|
13
|
+
}
|
|
14
|
+
renderPage(req, res, "mbkbucket_info.handlebars", false, { page: "MBK Bucket Info", CurrentVersion: packageJson.version, latestVersion, APP_NAME: mbkautheVar?.APP_NAME });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
router.get(['/info.json', "/i.json"], async (req, res) => {
|
|
19
|
+
let latestVersion = 'unknown';
|
|
20
|
+
try {
|
|
21
|
+
latestVersion = await getLatestVersion();
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error("[mbkauthe] Error fetching package-lock.json:", err);
|
|
24
|
+
}
|
|
25
|
+
res.json({CurrentVersion:packageJson.version, latestVersion, APP_NAME: mbkautheVar?.APP_NAME });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export default router;
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { validateSessionAndRole } from "mbkauthe";
|
|
3
|
+
import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, generateSignedUrl } from "../s3.js";
|
|
4
|
+
import { mbkbucketVar } from "../config/index.js";
|
|
5
|
+
import rateLimit from "express-rate-limit";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// p_view security: rate limiter + basic bot/security middleware
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
const pviewRateLimit = rateLimit({
|
|
11
|
+
windowMs: 60 * 1000, // 1-minute window
|
|
12
|
+
max: 10, // max 10 requests per IP per minute
|
|
13
|
+
standardHeaders: 'draft-7',
|
|
14
|
+
legacyHeaders: false,
|
|
15
|
+
keyGenerator: (req) => req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown',
|
|
16
|
+
handler: (_req, res) => {
|
|
17
|
+
res.status(429).json({ message: 'Too many requests. Please slow down and try again later.' });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Blocks obviously headless/scraper User-Agents and missing UAs
|
|
22
|
+
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;
|
|
23
|
+
|
|
24
|
+
// Suspicious header patterns that indicate automated access
|
|
25
|
+
const SUSPICIOUS_HEADERS = [
|
|
26
|
+
'x-devtools-emulate-network-conditions-client-id',
|
|
27
|
+
'x-chromedriver-clientid',
|
|
28
|
+
'x-automated-tool'
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function pviewSecurity(req, res, next) {
|
|
32
|
+
const ua = req.headers['user-agent'] || '';
|
|
33
|
+
|
|
34
|
+
// Block empty or obviously automated UAs
|
|
35
|
+
if (!ua || BLOCKED_UA_PATTERNS.test(ua)) {
|
|
36
|
+
return res.status(403).json({ message: 'Forbidden' });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Block suspicious headers that indicate automation
|
|
40
|
+
const hasSuspiciousHeaders = SUSPICIOUS_HEADERS.some(header => header in req.headers);
|
|
41
|
+
if (hasSuspiciousHeaders) {
|
|
42
|
+
return res.status(403).json({ message: 'Forbidden' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Rate limit check - ensure unique fingerprint per request
|
|
46
|
+
const fingerprint = `${req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress}:${ua}`;
|
|
47
|
+
|
|
48
|
+
// Verify Accept header is present (legitimate browsers always send this)
|
|
49
|
+
if (!req.headers['accept']) {
|
|
50
|
+
return res.status(403).json({ message: 'Forbidden' });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Set security headers for the public view response
|
|
54
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
55
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
56
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
57
|
+
// Fix CORS - either allow all (public files) or restrict to specific origins
|
|
58
|
+
// Using 'null' is invalid; we'll disable CORS entirely for public view since it's auth-restricted
|
|
59
|
+
res.removeHeader('Access-Control-Allow-Origin');
|
|
60
|
+
|
|
61
|
+
next();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const router = express.Router();
|
|
65
|
+
|
|
66
|
+
// New: lightweight player page for robust playback in browsers
|
|
67
|
+
router.get('/player/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
68
|
+
try {
|
|
69
|
+
const key = req.params.key;
|
|
70
|
+
let keyToUse;
|
|
71
|
+
try {
|
|
72
|
+
keyToUse = ensureKeyHasAppPrefix(key);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return res.status(400).send(e.message);
|
|
75
|
+
}
|
|
76
|
+
const encoded = encodeURIComponent(keyToUse);
|
|
77
|
+
const fileName = keyToUse.split('/').pop();
|
|
78
|
+
|
|
79
|
+
const html = `<!doctype html>
|
|
80
|
+
<html lang="en">
|
|
81
|
+
<head>
|
|
82
|
+
<meta charset="utf-8">
|
|
83
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
84
|
+
<title>Preview: ${fileName}</title>
|
|
85
|
+
<style>
|
|
86
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
87
|
+
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; }
|
|
88
|
+
.player-container { width: 100%; max-width: 100vw; max-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
|
89
|
+
video { width: 100%; height: auto; max-width: 100%; max-height: calc(100vh - 40px); object-fit: contain; background: #000; }
|
|
90
|
+
.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; }
|
|
91
|
+
</style>
|
|
92
|
+
</head>
|
|
93
|
+
<body>
|
|
94
|
+
<div class="file-name">${fileName}</div>
|
|
95
|
+
<div class="player-container">
|
|
96
|
+
<video controls preload="metadata" src="/mbkbucket/view/${encoded}">
|
|
97
|
+
Your browser does not support the video tag.
|
|
98
|
+
</video>
|
|
99
|
+
</div>
|
|
100
|
+
</body>
|
|
101
|
+
</html>`;
|
|
102
|
+
|
|
103
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
104
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
105
|
+
return res.status(200).send(html);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error('Error rendering player page:', err);
|
|
108
|
+
return res.status(500).send('Failed to render player');
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Shared constants
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
const VIEWABLE_TYPES = new Set([
|
|
116
|
+
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico',
|
|
117
|
+
'mp4', 'webm', 'ogg', 'avi', 'mov',
|
|
118
|
+
'mp3', 'wav', 'flac', 'aac', 'm4a',
|
|
119
|
+
'txt', 'md', 'json', 'xml', 'csv', 'log',
|
|
120
|
+
'js', 'ts', 'html', 'htm', 'css', 'php', 'py', 'java', 'cpp', 'c', 'h',
|
|
121
|
+
'cs', 'rb', 'go', 'rs', 'sql', 'sh', 'bat', 'ps1', 'yaml', 'yml',
|
|
122
|
+
'toml', 'ini', 'conf', 'pdf'
|
|
123
|
+
]);
|
|
124
|
+
// Pre-sorted for 415 error responses (avoids re-sorting on every error)
|
|
125
|
+
const VIEWABLE_TYPES_SORTED = Array.from(VIEWABLE_TYPES).sort();
|
|
126
|
+
|
|
127
|
+
const MIME_TYPES = {
|
|
128
|
+
// Images
|
|
129
|
+
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
|
|
130
|
+
'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
|
|
131
|
+
'bmp': 'image/bmp', 'ico': 'image/x-icon',
|
|
132
|
+
// Videos
|
|
133
|
+
'mp4': 'video/mp4', 'webm': 'video/webm', 'ogg': 'video/ogg',
|
|
134
|
+
'avi': 'video/x-msvideo', 'mov': 'video/quicktime',
|
|
135
|
+
// Audio
|
|
136
|
+
'mp3': 'audio/mpeg', 'wav': 'audio/wav', 'flac': 'audio/flac',
|
|
137
|
+
'aac': 'audio/aac', 'm4a': 'audio/mp4',
|
|
138
|
+
// Text / Code
|
|
139
|
+
'txt': 'text/plain; charset=utf-8', 'md': 'text/markdown; charset=utf-8',
|
|
140
|
+
'json': 'application/json; charset=utf-8', 'xml': 'application/xml; charset=utf-8',
|
|
141
|
+
'csv': 'text/csv; charset=utf-8', 'log': 'text/plain; charset=utf-8',
|
|
142
|
+
'js': 'text/javascript; charset=utf-8', 'ts': 'text/typescript; charset=utf-8',
|
|
143
|
+
'html': 'text/html; charset=utf-8', 'htm': 'text/html; charset=utf-8',
|
|
144
|
+
'css': 'text/css; charset=utf-8', 'php': 'text/x-php; charset=utf-8',
|
|
145
|
+
'py': 'text/x-python; charset=utf-8', 'java': 'text/x-java-source; charset=utf-8',
|
|
146
|
+
'cpp': 'text/x-c++src; charset=utf-8', 'c': 'text/x-csrc; charset=utf-8',
|
|
147
|
+
'h': 'text/x-chdr; charset=utf-8', 'cs': 'text/x-csharp; charset=utf-8',
|
|
148
|
+
'rb': 'text/x-ruby; charset=utf-8', 'go': 'text/x-go; charset=utf-8',
|
|
149
|
+
'rs': 'text/x-rust; charset=utf-8', 'sql': 'text/x-sql; charset=utf-8',
|
|
150
|
+
'sh': 'text/x-shellscript; charset=utf-8', 'bat': 'text/x-msdos-batch; charset=utf-8',
|
|
151
|
+
'ps1': 'text/x-powershell; charset=utf-8', 'yaml': 'text/x-yaml; charset=utf-8',
|
|
152
|
+
'yml': 'text/x-yaml; charset=utf-8', 'toml': 'text/x-toml; charset=utf-8',
|
|
153
|
+
'ini': 'text/x-ini; charset=utf-8', 'conf': 'text/x-conf; charset=utf-8',
|
|
154
|
+
// Documents
|
|
155
|
+
'pdf': 'application/pdf'
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Shared inline-serve helper
|
|
160
|
+
// Options:
|
|
161
|
+
// noindex {boolean} – adds X-Robots-Tag: noindex, nofollow (default false)
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
|
|
164
|
+
const fileName = keyToUse.split('/').pop();
|
|
165
|
+
const fileExtension = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : '';
|
|
166
|
+
|
|
167
|
+
console.log(`[mbkbucket] 📥 Request for ${fileName} (type: ${fileExtension}, range: ${req.headers.range || 'none'})`);
|
|
168
|
+
|
|
169
|
+
if (!VIEWABLE_TYPES.has(fileExtension)) {
|
|
170
|
+
return res.status(415).json({
|
|
171
|
+
message: 'File type not supported for viewing',
|
|
172
|
+
supportedTypes: VIEWABLE_TYPES_SORTED
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const ifNoneMatch = req.headers['if-none-match'];
|
|
177
|
+
const ifModifiedSince = req.headers['if-modified-since'];
|
|
178
|
+
const rangeHeader = req.headers.range;
|
|
179
|
+
|
|
180
|
+
const isStaticAsset = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'css', 'js'].includes(fileExtension);
|
|
181
|
+
const isVideo = ['mp4', 'webm', 'ogg', 'avi', 'mov'].includes(fileExtension);
|
|
182
|
+
const isAudio = ['mp3', 'wav', 'flac', 'aac', 'm4a'].includes(fileExtension);
|
|
183
|
+
const isPdf = fileExtension === 'pdf';
|
|
184
|
+
const supportsRanges = isVideo || isAudio || isPdf;
|
|
185
|
+
const contentType = MIME_TYPES[fileExtension] || 'application/octet-stream';
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Fast path for range requests (video / audio / PDF streaming)
|
|
189
|
+
// Use a cheap HEAD (getFileMetadata) to get size + ETag, then fetch ONLY the
|
|
190
|
+
// requested byte range — avoids downloading the full file first.
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
if (supportsRanges && rangeHeader) {
|
|
193
|
+
console.log(`[mbkbucket] Processing range request: ${rangeHeader} for ${fileName}`);
|
|
194
|
+
let meta;
|
|
195
|
+
try {
|
|
196
|
+
meta = await getFileMetadata(keyToUse);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.error('[mbkbucket] serveFileInline metadata error:', err);
|
|
199
|
+
return res.status(500).json({ message: 'Failed to fetch file metadata', error: err.message });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!meta || !meta.exists) {
|
|
203
|
+
return res.status(404).json({ message: 'File not found', key: keyToUse });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const etag = `"${meta.LastModified?.getTime() || Date.now()}-${meta.ContentLength || 0}"`;
|
|
207
|
+
if (ifNoneMatch === etag) return res.status(304).end();
|
|
208
|
+
|
|
209
|
+
const total = Number(meta.ContentLength);
|
|
210
|
+
if (!total) {
|
|
211
|
+
// No content-length — fall through to full-file path
|
|
212
|
+
} else {
|
|
213
|
+
const CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB
|
|
214
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
|
|
215
|
+
if (!match) {
|
|
216
|
+
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
217
|
+
return res.end();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const [, startStr, endStr] = match;
|
|
221
|
+
let start, end;
|
|
222
|
+
|
|
223
|
+
if (startStr === '') {
|
|
224
|
+
const suffix = parseInt(endStr, 10);
|
|
225
|
+
if (isNaN(suffix) || suffix <= 0) {
|
|
226
|
+
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
227
|
+
return res.end();
|
|
228
|
+
}
|
|
229
|
+
start = Math.max(0, total - suffix);
|
|
230
|
+
end = total - 1;
|
|
231
|
+
} else {
|
|
232
|
+
start = parseInt(startStr, 10);
|
|
233
|
+
if (isNaN(start) || start < 0) {
|
|
234
|
+
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
235
|
+
return res.end();
|
|
236
|
+
}
|
|
237
|
+
end = endStr === '' ? Math.min(start + CHUNK_SIZE - 1, total - 1) : parseInt(endStr, 10);
|
|
238
|
+
if (isNaN(end) || end < start) {
|
|
239
|
+
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
240
|
+
return res.end();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (start >= total || start > end || end >= total) {
|
|
245
|
+
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
246
|
+
return res.end();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const chunksize = end - start + 1;
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}` });
|
|
253
|
+
|
|
254
|
+
if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
|
|
255
|
+
console.warn(`Range size mismatch for ${keyToUse}: expected=${chunksize} got=${rangeResult.ContentLength}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
console.log(`[mbkbucket] ✓ Serving range bytes ${start}-${end}/${total} (${chunksize} bytes) for ${fileName}`);
|
|
259
|
+
|
|
260
|
+
res.status(206);
|
|
261
|
+
res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
|
|
262
|
+
res.setHeader('Content-Length', chunksize);
|
|
263
|
+
res.setHeader('Content-Type', contentType);
|
|
264
|
+
res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate');
|
|
265
|
+
res.setHeader('ETag', etag);
|
|
266
|
+
res.setHeader('Accept-Ranges', 'bytes');
|
|
267
|
+
if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
|
|
268
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
269
|
+
if (meta.LastModified) res.setHeader('Last-Modified', meta.LastModified.toUTCString());
|
|
270
|
+
|
|
271
|
+
// Comprehensive stream error handling and cleanup
|
|
272
|
+
const cleanupStream = () => {
|
|
273
|
+
if (rangeResult.Body && typeof rangeResult.Body.destroy === 'function') {
|
|
274
|
+
rangeResult.Body.removeAllListeners();
|
|
275
|
+
rangeResult.Body.destroy();
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
rangeResult.Body.on('error', (err) => {
|
|
280
|
+
console.error('Range stream error:', err);
|
|
281
|
+
cleanupStream();
|
|
282
|
+
if (!res.headersSent) res.status(500).end('Stream error');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
req.on('close', cleanupStream);
|
|
286
|
+
req.on('aborted', cleanupStream);
|
|
287
|
+
|
|
288
|
+
req.setTimeout(120000, () => {
|
|
289
|
+
console.error('Range request timeout for file:', keyToUse);
|
|
290
|
+
cleanupStream();
|
|
291
|
+
if (!res.headersSent) res.status(408).end('Request timeout');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
res.on('finish', cleanupStream);
|
|
295
|
+
res.on('close', cleanupStream);
|
|
296
|
+
|
|
297
|
+
return rangeResult.Body.pipe(res);
|
|
298
|
+
|
|
299
|
+
} catch (rangeError) {
|
|
300
|
+
console.error('Range request failed:', rangeError);
|
|
301
|
+
// fall through to full-file path
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Standard path: full-file download (non-media, or media without Range header)
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
const downloadOptions = {
|
|
310
|
+
...(ifNoneMatch && { ifNoneMatch }),
|
|
311
|
+
...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) })
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
if (isPdf) {
|
|
315
|
+
console.log(`[mbkbucket] Serving full PDF (no range header) for ${fileName}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let result;
|
|
319
|
+
try {
|
|
320
|
+
result = await downloadFile(keyToUse, downloadOptions);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (err.name === 'NoSuchKey' || err.name === 'NotFound') {
|
|
323
|
+
return res.status(404).json({ message: 'File not found', key: keyToUse });
|
|
324
|
+
} else if (err.name === 'AccessDenied') {
|
|
325
|
+
return res.status(403).json({ message: 'Access denied' });
|
|
326
|
+
}
|
|
327
|
+
console.error('[mbkbucket] serveFileInline download error:', err);
|
|
328
|
+
return res.status(500).json({ message: 'Failed to fetch file', error: err.message });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (result && result.notModified) return res.status(304).end();
|
|
332
|
+
|
|
333
|
+
const etag = `"${result.LastModified?.getTime() || Date.now()}-${result.ContentLength || 0}"`;
|
|
334
|
+
if (ifNoneMatch === etag) return res.status(304).end();
|
|
335
|
+
|
|
336
|
+
res.setHeader('Content-Type', contentType);
|
|
337
|
+
res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
|
|
338
|
+
res.setHeader('ETag', etag);
|
|
339
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
340
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
341
|
+
if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
|
|
342
|
+
if (result.ContentLength) res.setHeader('Content-Length', result.ContentLength);
|
|
343
|
+
if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
|
|
344
|
+
if (fileExtension === 'html' || fileExtension === 'htm') {
|
|
345
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
|
|
346
|
+
}
|
|
347
|
+
if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
|
|
348
|
+
|
|
349
|
+
if (isStaticAsset) {
|
|
350
|
+
res.setHeader('Cache-Control', 'public, max-age=3600, immutable');
|
|
351
|
+
} else if (supportsRanges) {
|
|
352
|
+
// For video, audio, and PDF - enable caching with revalidation
|
|
353
|
+
res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate');
|
|
354
|
+
} else {
|
|
355
|
+
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
res.status(200);
|
|
359
|
+
|
|
360
|
+
// Comprehensive stream cleanup to prevent memory leaks
|
|
361
|
+
const cleanupStream = () => {
|
|
362
|
+
if (result.Body && typeof result.Body.destroy === 'function') {
|
|
363
|
+
result.Body.removeAllListeners();
|
|
364
|
+
result.Body.destroy();
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
result.Body.on('error', (err) => {
|
|
369
|
+
console.error('Stream error:', err);
|
|
370
|
+
cleanupStream();
|
|
371
|
+
if (!res.headersSent) res.status(500).json({ message: 'Error streaming file', error: err.message });
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
req.on('close', cleanupStream);
|
|
375
|
+
req.on('aborted', cleanupStream);
|
|
376
|
+
|
|
377
|
+
const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
|
|
378
|
+
req.setTimeout(timeoutDuration, () => {
|
|
379
|
+
console.error(`Request timeout (${timeoutDuration}ms) for file: ${keyToUse}, size: ${result.ContentLength}`);
|
|
380
|
+
cleanupStream();
|
|
381
|
+
if (!res.headersSent) res.status(408).json({ message: 'Request timeout' });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
res.on('finish', cleanupStream);
|
|
385
|
+
res.on('close', cleanupStream);
|
|
386
|
+
|
|
387
|
+
result.Body.pipe(res);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Routes
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
router.get('/view/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
395
|
+
try {
|
|
396
|
+
let keyToUse;
|
|
397
|
+
try {
|
|
398
|
+
keyToUse = ensureKeyHasAppPrefix(req.params.key);
|
|
399
|
+
} catch (e) {
|
|
400
|
+
return res.status(400).json({ message: e.message });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await serveFileInline(req, res, keyToUse, { noindex: true });
|
|
404
|
+
|
|
405
|
+
} catch (error) {
|
|
406
|
+
console.error("Error viewing file:", error);
|
|
407
|
+
if (error.name === 'NoSuchKey' || error.name === 'NotFound') {
|
|
408
|
+
res.status(404).json({ message: "File not found", key: req.params.key });
|
|
409
|
+
} else if (error.name === 'AccessDenied') {
|
|
410
|
+
res.status(403).json({ message: "Access denied" });
|
|
411
|
+
} else {
|
|
412
|
+
res.status(500).json({ message: "View failed", error: error.message });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
if (mbkbucketVar?.publiView_enabled) {
|
|
418
|
+
router.get('/p_view/:key(*)', pviewRateLimit, pviewSecurity, async (req, res) => {
|
|
419
|
+
try {
|
|
420
|
+
const key = req.params.key;
|
|
421
|
+
|
|
422
|
+
// No directory indexing — reject folder-like or empty keys
|
|
423
|
+
if (!key || key.endsWith('/')) {
|
|
424
|
+
return res.status(403).json({ message: 'Directory indexing is not allowed' });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let keyToUse;
|
|
428
|
+
try {
|
|
429
|
+
keyToUse = ensureKeyHasAppPrefix(key);
|
|
430
|
+
} catch (e) {
|
|
431
|
+
return res.status(400).json({ message: e.message });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Determine pview_b: check bucket config first, then env var
|
|
435
|
+
let p_view_inline = !!mbkbucketVar?.p_view_inline;
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
if (!p_view_inline) {
|
|
439
|
+
// Signed URL mode — redirect to a pre-signed S3 URL (1 hour expiry)
|
|
440
|
+
try {
|
|
441
|
+
const signed = await generateSignedUrl(keyToUse, 'getObject', 3600);
|
|
442
|
+
return res.redirect(302, signed.url);
|
|
443
|
+
} catch (err) {
|
|
444
|
+
console.error('[mbkbucket] p_view signed URL error:', err);
|
|
445
|
+
return res.status(500).json({ message: 'Failed to generate access URL' });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Direct serve mode — stream file inline (with noindex header)
|
|
450
|
+
await serveFileInline(req, res, keyToUse, { noindex: true });
|
|
451
|
+
} catch (error) {
|
|
452
|
+
console.error('[mbkbucket] p_view error:', error);
|
|
453
|
+
res.status(500).json({ message: 'Internal server error', error: error.message });
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export default router;
|