mbkbucket 2.1.1 → 3.0.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/.github/PACKAGE.md +6 -0
- package/.mbkbucket.config.example.json +14 -0
- package/README.md +261 -81
- package/cli.js +977 -0
- package/index.js +18 -140
- package/lib/index.js +7 -7
- package/package.json +11 -4
- package/public/bucketadmin.js +36 -4
- package/src/app.js +110 -0
- package/src/cli/auth-helper.js +223 -0
- package/{lib → src}/config/index.js +26 -19
- package/{lib/routes/api.js → src/controllers/api.controller.js} +125 -221
- package/{lib/routes/index.js → src/controllers/dashboard.controller.js} +8 -36
- package/src/controllers/info.controller.js +37 -0
- package/src/controllers/view.controller.js +276 -0
- package/src/middleware/async-handler.js +38 -0
- package/src/middleware/bucket-resolver.js +43 -0
- package/src/middleware/error-handler.js +34 -0
- package/src/middleware/rate-limiter.js +30 -0
- package/src/middleware/security.js +43 -0
- package/src/routes/api.routes.js +66 -0
- package/src/routes/index.js +22 -0
- package/src/routes/info.routes.js +10 -0
- package/src/routes/view.routes.js +25 -0
- package/{lib/s3.js → src/services/s3.service.js} +115 -191
- package/src/utils/errors.js +76 -0
- package/src/utils/helpers.js +173 -0
- package/{lib/debug.js → src/utils/logger.js} +5 -0
- package/test/config-validation.test.js +1 -1
- package/lib/routes/info.js +0 -28
- package/lib/routes/view.js +0 -531
- /package/{lib → src}/config/validation.js +0 -0
package/lib/routes/view.js
DELETED
|
@@ -1,531 +0,0 @@
|
|
|
1
|
-
import express from "express";
|
|
2
|
-
import { sessRole } from "mbkauthe";
|
|
3
|
-
import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, generateSignedUrl, resolveBucketName } from "../s3.js";
|
|
4
|
-
import { mbkbucketVar } from "../config/index.js";
|
|
5
|
-
import rateLimit from "express-rate-limit";
|
|
6
|
-
import { createLogger } from "../debug.js";
|
|
7
|
-
|
|
8
|
-
const debugView = createLogger('view');
|
|
9
|
-
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
// p_view security: rate limiter + basic bot/security middleware
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
const pviewRateLimit = rateLimit({
|
|
14
|
-
windowMs: 60 * 1000, // 1-minute window
|
|
15
|
-
max: 10, // max 10 requests per IP per minute
|
|
16
|
-
standardHeaders: 'draft-7',
|
|
17
|
-
legacyHeaders: false,
|
|
18
|
-
keyGenerator: (req) => req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown',
|
|
19
|
-
handler: (_req, res) => {
|
|
20
|
-
res.status(429).json({ message: 'Too many requests. Please slow down and try again later.' });
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
// Blocks obviously headless/scraper User-Agents and missing UAs
|
|
25
|
-
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;
|
|
26
|
-
|
|
27
|
-
// Suspicious header patterns that indicate automated access
|
|
28
|
-
const SUSPICIOUS_HEADERS = [
|
|
29
|
-
'x-devtools-emulate-network-conditions-client-id',
|
|
30
|
-
'x-chromedriver-clientid',
|
|
31
|
-
'x-automated-tool'
|
|
32
|
-
];
|
|
33
|
-
|
|
34
|
-
function pviewSecurity(req, res, next) {
|
|
35
|
-
const ua = req.headers['user-agent'] || '';
|
|
36
|
-
|
|
37
|
-
// Block empty or obviously automated UAs
|
|
38
|
-
if (!ua || BLOCKED_UA_PATTERNS.test(ua)) {
|
|
39
|
-
return res.status(403).json({ message: 'Forbidden' });
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Block suspicious headers that indicate automation
|
|
43
|
-
const hasSuspiciousHeaders = SUSPICIOUS_HEADERS.some(header => header in req.headers);
|
|
44
|
-
if (hasSuspiciousHeaders) {
|
|
45
|
-
return res.status(403).json({ message: 'Forbidden' });
|
|
46
|
-
}
|
|
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
|
-
function escapeHtml(value = '') {
|
|
67
|
-
return String(value)
|
|
68
|
-
.replace(/&/g, '&')
|
|
69
|
-
.replace(/</g, '<')
|
|
70
|
-
.replace(/>/g, '>')
|
|
71
|
-
.replace(/"/g, '"')
|
|
72
|
-
.replace(/'/g, ''');
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function getBaseName(path = '') {
|
|
76
|
-
const value = String(path || '');
|
|
77
|
-
const lastSlashIndex = value.lastIndexOf('/');
|
|
78
|
-
return lastSlashIndex === -1 ? value : value.substring(lastSlashIndex + 1);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function classifyViewError(error, fallbackMessage = 'Request failed') {
|
|
82
|
-
const message = String(error?.message || fallbackMessage);
|
|
83
|
-
const lower = message.toLowerCase();
|
|
84
|
-
|
|
85
|
-
if (lower.includes('no bucket selected') || lower.includes('not found in bucketconnection')) {
|
|
86
|
-
return { status: 400, code: 'INVALID_BUCKET', message };
|
|
87
|
-
}
|
|
88
|
-
if (lower.includes('access denied')) {
|
|
89
|
-
return { status: 403, code: 'ACCESS_DENIED', message };
|
|
90
|
-
}
|
|
91
|
-
if (lower.includes('not found')) {
|
|
92
|
-
return { status: 404, code: 'NOT_FOUND', message };
|
|
93
|
-
}
|
|
94
|
-
if (lower.includes('invalid') || lower.includes('required')) {
|
|
95
|
-
return { status: 400, code: 'VALIDATION_ERROR', message };
|
|
96
|
-
}
|
|
97
|
-
return { status: 500, code: 'INTERNAL_ERROR', message };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function sendViewError(res, error, fallbackMessage = 'Request failed') {
|
|
101
|
-
const mapped = classifyViewError(error, fallbackMessage);
|
|
102
|
-
return res.status(mapped.status).json({ code: mapped.code, message: mapped.message });
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Central bucket guard for view routes except public view (forced default bucket).
|
|
106
|
-
router.use((req, res, next) => {
|
|
107
|
-
if (req.path.startsWith('/p_view/')) return next();
|
|
108
|
-
if (req.bucketResolveError) return sendViewError(res, req.bucketResolveError, 'Invalid bucket selection');
|
|
109
|
-
if (!req.activeBucket) {
|
|
110
|
-
return sendViewError(res, new Error('No bucket selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.'));
|
|
111
|
-
}
|
|
112
|
-
next();
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
// New: lightweight player page for robust playback in browsers
|
|
116
|
-
router.get('/player/:key(*)', sessRole('SuperAdmin'), async (req, res) => {
|
|
117
|
-
try {
|
|
118
|
-
const bucketName = req.activeBucket;
|
|
119
|
-
const key = req.params.key;
|
|
120
|
-
let keyToUse;
|
|
121
|
-
try {
|
|
122
|
-
keyToUse = ensureKeyHasAppPrefix(key);
|
|
123
|
-
} catch (e) {
|
|
124
|
-
return res.status(400).send(e.message);
|
|
125
|
-
}
|
|
126
|
-
const encoded = encodeURIComponent(keyToUse);
|
|
127
|
-
const fileName = getBaseName(keyToUse);
|
|
128
|
-
const safeFileName = escapeHtml(fileName);
|
|
129
|
-
|
|
130
|
-
const bucketQuery = `?bucket=${encodeURIComponent(bucketName)}`;
|
|
131
|
-
const html = `<!doctype html>
|
|
132
|
-
<html lang="en">
|
|
133
|
-
<head>
|
|
134
|
-
<meta charset="utf-8">
|
|
135
|
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
136
|
-
<title>Preview: ${safeFileName}</title>
|
|
137
|
-
<style>
|
|
138
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
139
|
-
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; }
|
|
140
|
-
.player-container { width: 100%; max-width: 100vw; max-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
|
141
|
-
video { width: 100%; height: auto; max-width: 100%; max-height: calc(100vh - 40px); object-fit: contain; background: #000; }
|
|
142
|
-
.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; }
|
|
143
|
-
</style>
|
|
144
|
-
</head>
|
|
145
|
-
<body>
|
|
146
|
-
<div class="file-name">${safeFileName}</div>
|
|
147
|
-
<div class="player-container">
|
|
148
|
-
<video controls preload="metadata" src="/mbkbucket/view/${encoded}${bucketQuery}">
|
|
149
|
-
Your browser does not support the video tag.
|
|
150
|
-
</video>
|
|
151
|
-
</div>
|
|
152
|
-
</body>
|
|
153
|
-
</html>`;
|
|
154
|
-
|
|
155
|
-
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
156
|
-
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
157
|
-
return res.status(200).send(html);
|
|
158
|
-
} catch (err) {
|
|
159
|
-
console.error('Error rendering player page:', err);
|
|
160
|
-
return res.status(500).send('Failed to render player');
|
|
161
|
-
}
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
// ---------------------------------------------------------------------------
|
|
165
|
-
// Shared constants
|
|
166
|
-
// ---------------------------------------------------------------------------
|
|
167
|
-
const VIEWABLE_TYPES = new Set([
|
|
168
|
-
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico',
|
|
169
|
-
'mp4', 'webm', 'ogg', 'avi', 'mov',
|
|
170
|
-
'mp3', 'wav', 'flac', 'aac', 'm4a',
|
|
171
|
-
'txt', 'md', 'json', 'xml', 'csv', 'log',
|
|
172
|
-
'js', 'ts', 'html', 'htm', 'css', 'php', 'py', 'java', 'cpp', 'c', 'h',
|
|
173
|
-
'cs', 'rb', 'go', 'rs', 'sql', 'sh', 'bat', 'ps1', 'yaml', 'yml',
|
|
174
|
-
'toml', 'ini', 'conf', 'pdf'
|
|
175
|
-
]);
|
|
176
|
-
// Pre-sorted for 415 error responses (avoids re-sorting on every error)
|
|
177
|
-
const VIEWABLE_TYPES_SORTED = Array.from(VIEWABLE_TYPES).sort();
|
|
178
|
-
const STATIC_ASSET_TYPES = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'css', 'js']);
|
|
179
|
-
const VIDEO_TYPES = new Set(['mp4', 'webm', 'ogg', 'avi', 'mov']);
|
|
180
|
-
const AUDIO_TYPES = new Set(['mp3', 'wav', 'flac', 'aac', 'm4a']);
|
|
181
|
-
|
|
182
|
-
const MIME_TYPES = {
|
|
183
|
-
// Images
|
|
184
|
-
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
|
|
185
|
-
'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
|
|
186
|
-
'bmp': 'image/bmp', 'ico': 'image/x-icon',
|
|
187
|
-
// Videos
|
|
188
|
-
'mp4': 'video/mp4', 'webm': 'video/webm', 'ogg': 'video/ogg',
|
|
189
|
-
'avi': 'video/x-msvideo', 'mov': 'video/quicktime',
|
|
190
|
-
// Audio
|
|
191
|
-
'mp3': 'audio/mpeg', 'wav': 'audio/wav', 'flac': 'audio/flac',
|
|
192
|
-
'aac': 'audio/aac', 'm4a': 'audio/mp4',
|
|
193
|
-
// Text / Code
|
|
194
|
-
'txt': 'text/plain; charset=utf-8', 'md': 'text/markdown; charset=utf-8',
|
|
195
|
-
'json': 'application/json; charset=utf-8', 'xml': 'application/xml; charset=utf-8',
|
|
196
|
-
'csv': 'text/csv; charset=utf-8', 'log': 'text/plain; charset=utf-8',
|
|
197
|
-
'js': 'text/javascript; charset=utf-8', 'ts': 'text/typescript; charset=utf-8',
|
|
198
|
-
'html': 'text/html; charset=utf-8', 'htm': 'text/html; charset=utf-8',
|
|
199
|
-
'css': 'text/css; charset=utf-8', 'php': 'text/x-php; charset=utf-8',
|
|
200
|
-
'py': 'text/x-python; charset=utf-8', 'java': 'text/x-java-source; charset=utf-8',
|
|
201
|
-
'cpp': 'text/x-c++src; charset=utf-8', 'c': 'text/x-csrc; charset=utf-8',
|
|
202
|
-
'h': 'text/x-chdr; charset=utf-8', 'cs': 'text/x-csharp; charset=utf-8',
|
|
203
|
-
'rb': 'text/x-ruby; charset=utf-8', 'go': 'text/x-go; charset=utf-8',
|
|
204
|
-
'rs': 'text/x-rust; charset=utf-8', 'sql': 'text/x-sql; charset=utf-8',
|
|
205
|
-
'sh': 'text/x-shellscript; charset=utf-8', 'bat': 'text/x-msdos-batch; charset=utf-8',
|
|
206
|
-
'ps1': 'text/x-powershell; charset=utf-8', 'yaml': 'text/x-yaml; charset=utf-8',
|
|
207
|
-
'yml': 'text/x-yaml; charset=utf-8', 'toml': 'text/x-toml; charset=utf-8',
|
|
208
|
-
'ini': 'text/x-ini; charset=utf-8', 'conf': 'text/x-conf; charset=utf-8',
|
|
209
|
-
// Documents
|
|
210
|
-
'pdf': 'application/pdf'
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
// ---------------------------------------------------------------------------
|
|
214
|
-
// Shared inline-serve helper
|
|
215
|
-
// Options:
|
|
216
|
-
// noindex {boolean} – adds X-Robots-Tag: noindex, nofollow (default false)
|
|
217
|
-
// ---------------------------------------------------------------------------
|
|
218
|
-
function isSensitiveTextType(contentType = '') {
|
|
219
|
-
const type = String(contentType || '').toLowerCase();
|
|
220
|
-
return type.startsWith('text/')
|
|
221
|
-
|| type.includes('json')
|
|
222
|
-
|| type.includes('xml')
|
|
223
|
-
|| type.includes('javascript')
|
|
224
|
-
|| type.includes('typescript');
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function buildCacheControl(contentType, { isStaticAsset = false, supportsRanges = false, publicCache = false } = {}) {
|
|
228
|
-
if (publicCache) {
|
|
229
|
-
if (isStaticAsset) return 'public, max-age=3600, immutable';
|
|
230
|
-
if (supportsRanges) return 'public, max-age=86400, must-revalidate';
|
|
231
|
-
return 'public, max-age=300, must-revalidate';
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (isSensitiveTextType(contentType)) return 'private, no-store';
|
|
235
|
-
if (supportsRanges) return 'private, max-age=1800, must-revalidate';
|
|
236
|
-
return 'private, max-age=300, must-revalidate';
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName, publicCache = false } = {}) {
|
|
240
|
-
const fileName = getBaseName(keyToUse);
|
|
241
|
-
const dotIndex = fileName.lastIndexOf('.');
|
|
242
|
-
const fileExtension = dotIndex === -1 ? '' : fileName.substring(dotIndex + 1).toLowerCase();
|
|
243
|
-
|
|
244
|
-
debugView('Request for %s (type: %s, range: %s)', fileName, fileExtension, req.headers.range || 'none');
|
|
245
|
-
|
|
246
|
-
if (!VIEWABLE_TYPES.has(fileExtension)) {
|
|
247
|
-
return res.status(415).json({
|
|
248
|
-
message: 'File type not supported for viewing',
|
|
249
|
-
supportedTypes: VIEWABLE_TYPES_SORTED
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const ifNoneMatch = req.headers['if-none-match'];
|
|
254
|
-
const ifModifiedSince = req.headers['if-modified-since'];
|
|
255
|
-
const rangeHeader = req.headers.range;
|
|
256
|
-
|
|
257
|
-
const isStaticAsset = STATIC_ASSET_TYPES.has(fileExtension);
|
|
258
|
-
const isVideo = VIDEO_TYPES.has(fileExtension);
|
|
259
|
-
const isAudio = AUDIO_TYPES.has(fileExtension);
|
|
260
|
-
const isPdf = fileExtension === 'pdf';
|
|
261
|
-
const supportsRanges = isVideo || isAudio || isPdf;
|
|
262
|
-
const contentType = MIME_TYPES[fileExtension] || 'application/octet-stream';
|
|
263
|
-
|
|
264
|
-
// ---------------------------------------------------------------------------
|
|
265
|
-
// Fast path for range requests (video / audio / PDF streaming)
|
|
266
|
-
// Use a cheap HEAD (getFileMetadata) to get size + ETag, then fetch ONLY the
|
|
267
|
-
// requested byte range — avoids downloading the full file first.
|
|
268
|
-
// ---------------------------------------------------------------------------
|
|
269
|
-
if (supportsRanges && rangeHeader) {
|
|
270
|
-
debugView('Processing range request: %s for %s', rangeHeader, fileName);
|
|
271
|
-
let meta;
|
|
272
|
-
try {
|
|
273
|
-
meta = await getFileMetadata(keyToUse, bucketName);
|
|
274
|
-
} catch (err) {
|
|
275
|
-
console.error('[mbkbucket] serveFileInline metadata error:', err);
|
|
276
|
-
return res.status(500).json({ message: 'Failed to fetch file metadata', error: err.message });
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
if (!meta || !meta.exists) {
|
|
280
|
-
return res.status(404).json({ message: 'File not found', key: keyToUse });
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const etag = `"${meta.LastModified?.getTime() || Date.now()}-${meta.ContentLength || 0}"`;
|
|
284
|
-
if (ifNoneMatch === etag) return res.status(304).end();
|
|
285
|
-
|
|
286
|
-
const total = Number(meta.ContentLength);
|
|
287
|
-
if (!total) {
|
|
288
|
-
// No content-length — fall through to full-file path
|
|
289
|
-
} else {
|
|
290
|
-
const CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB
|
|
291
|
-
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
|
|
292
|
-
if (!match) {
|
|
293
|
-
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
294
|
-
return res.end();
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const [, startStr, endStr] = match;
|
|
298
|
-
let start, end;
|
|
299
|
-
|
|
300
|
-
if (startStr === '') {
|
|
301
|
-
const suffix = parseInt(endStr, 10);
|
|
302
|
-
if (isNaN(suffix) || suffix <= 0) {
|
|
303
|
-
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
304
|
-
return res.end();
|
|
305
|
-
}
|
|
306
|
-
start = Math.max(0, total - suffix);
|
|
307
|
-
end = total - 1;
|
|
308
|
-
} else {
|
|
309
|
-
start = parseInt(startStr, 10);
|
|
310
|
-
if (isNaN(start) || start < 0) {
|
|
311
|
-
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
312
|
-
return res.end();
|
|
313
|
-
}
|
|
314
|
-
end = endStr === '' ? Math.min(start + CHUNK_SIZE - 1, total - 1) : parseInt(endStr, 10);
|
|
315
|
-
if (isNaN(end) || end < start) {
|
|
316
|
-
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
317
|
-
return res.end();
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
if (start >= total || start > end || end >= total) {
|
|
322
|
-
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
323
|
-
return res.end();
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const chunksize = end - start + 1;
|
|
327
|
-
|
|
328
|
-
try {
|
|
329
|
-
const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, bucketName });
|
|
330
|
-
|
|
331
|
-
if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
|
|
332
|
-
debugView('Range size mismatch for %s: expected=%s got=%s', keyToUse, chunksize, rangeResult.ContentLength);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
debugView('Serving range bytes %s-%s/%s (%s bytes) for %s', start, end, total, chunksize, fileName);
|
|
336
|
-
|
|
337
|
-
res.status(206);
|
|
338
|
-
res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
|
|
339
|
-
res.setHeader('Content-Length', chunksize);
|
|
340
|
-
res.setHeader('Content-Type', contentType);
|
|
341
|
-
res.setHeader('Cache-Control', buildCacheControl(contentType, { supportsRanges: true, publicCache }));
|
|
342
|
-
res.setHeader('ETag', etag);
|
|
343
|
-
res.setHeader('Accept-Ranges', 'bytes');
|
|
344
|
-
if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
|
|
345
|
-
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
346
|
-
if (meta.LastModified) res.setHeader('Last-Modified', meta.LastModified.toUTCString());
|
|
347
|
-
|
|
348
|
-
// Comprehensive stream error handling and cleanup
|
|
349
|
-
const cleanupStream = () => {
|
|
350
|
-
if (rangeResult.Body && typeof rangeResult.Body.destroy === 'function') {
|
|
351
|
-
rangeResult.Body.removeAllListeners();
|
|
352
|
-
rangeResult.Body.destroy();
|
|
353
|
-
}
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
rangeResult.Body.on('error', (err) => {
|
|
357
|
-
console.error('Range stream error:', err);
|
|
358
|
-
cleanupStream();
|
|
359
|
-
if (!res.headersSent) res.status(500).end('Stream error');
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
req.on('close', cleanupStream);
|
|
363
|
-
req.on('aborted', cleanupStream);
|
|
364
|
-
|
|
365
|
-
req.setTimeout(120000, () => {
|
|
366
|
-
console.error('Range request timeout for file:', keyToUse);
|
|
367
|
-
cleanupStream();
|
|
368
|
-
if (!res.headersSent) res.status(408).end('Request timeout');
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
res.on('finish', cleanupStream);
|
|
372
|
-
res.on('close', cleanupStream);
|
|
373
|
-
|
|
374
|
-
return rangeResult.Body.pipe(res);
|
|
375
|
-
|
|
376
|
-
} catch (rangeError) {
|
|
377
|
-
console.error('Range request failed:', rangeError);
|
|
378
|
-
// fall through to full-file path
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// ---------------------------------------------------------------------------
|
|
384
|
-
// Standard path: full-file download (non-media, or media without Range header)
|
|
385
|
-
// ---------------------------------------------------------------------------
|
|
386
|
-
const downloadOptions = {
|
|
387
|
-
...(ifNoneMatch && { ifNoneMatch }),
|
|
388
|
-
...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) }),
|
|
389
|
-
bucketName
|
|
390
|
-
};
|
|
391
|
-
|
|
392
|
-
if (isPdf) {
|
|
393
|
-
debugView('Serving full PDF (no range header) for %s', fileName);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
let result;
|
|
397
|
-
try {
|
|
398
|
-
result = await downloadFile(keyToUse, downloadOptions);
|
|
399
|
-
} catch (err) {
|
|
400
|
-
if (err.name === 'NoSuchKey' || err.name === 'NotFound') {
|
|
401
|
-
return res.status(404).json({ message: 'File not found', key: keyToUse });
|
|
402
|
-
} else if (err.name === 'AccessDenied') {
|
|
403
|
-
return res.status(403).json({ message: 'Access denied' });
|
|
404
|
-
}
|
|
405
|
-
console.error('[mbkbucket] serveFileInline download error:', err);
|
|
406
|
-
return res.status(500).json({ message: 'Failed to fetch file', error: err.message });
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
if (result && result.notModified) return res.status(304).end();
|
|
410
|
-
|
|
411
|
-
const etag = `"${result.LastModified?.getTime() || Date.now()}-${result.ContentLength || 0}"`;
|
|
412
|
-
if (ifNoneMatch === etag) return res.status(304).end();
|
|
413
|
-
|
|
414
|
-
res.setHeader('Content-Type', contentType);
|
|
415
|
-
res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
|
|
416
|
-
res.setHeader('ETag', etag);
|
|
417
|
-
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
418
|
-
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
419
|
-
if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
|
|
420
|
-
if (result.ContentLength) res.setHeader('Content-Length', result.ContentLength);
|
|
421
|
-
if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
|
|
422
|
-
if (fileExtension === 'html' || fileExtension === 'htm') {
|
|
423
|
-
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
|
|
424
|
-
}
|
|
425
|
-
if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
|
|
426
|
-
|
|
427
|
-
res.setHeader('Cache-Control', buildCacheControl(contentType, { isStaticAsset, supportsRanges, publicCache }));
|
|
428
|
-
|
|
429
|
-
res.status(200);
|
|
430
|
-
|
|
431
|
-
// Comprehensive stream cleanup to prevent memory leaks
|
|
432
|
-
const cleanupStream = () => {
|
|
433
|
-
if (result.Body && typeof result.Body.destroy === 'function') {
|
|
434
|
-
result.Body.removeAllListeners();
|
|
435
|
-
result.Body.destroy();
|
|
436
|
-
}
|
|
437
|
-
};
|
|
438
|
-
|
|
439
|
-
result.Body.on('error', (err) => {
|
|
440
|
-
console.error('Stream error:', err);
|
|
441
|
-
cleanupStream();
|
|
442
|
-
if (!res.headersSent) res.status(500).json({ message: 'Error streaming file', error: err.message });
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
req.on('close', cleanupStream);
|
|
446
|
-
req.on('aborted', cleanupStream);
|
|
447
|
-
|
|
448
|
-
const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
|
|
449
|
-
req.setTimeout(timeoutDuration, () => {
|
|
450
|
-
console.error(`Request timeout (${timeoutDuration}ms) for file: ${keyToUse}, size: ${result.ContentLength}`);
|
|
451
|
-
cleanupStream();
|
|
452
|
-
if (!res.headersSent) res.status(408).json({ message: 'Request timeout' });
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
res.on('finish', cleanupStream);
|
|
456
|
-
res.on('close', cleanupStream);
|
|
457
|
-
|
|
458
|
-
result.Body.pipe(res);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// ---------------------------------------------------------------------------
|
|
462
|
-
// Routes
|
|
463
|
-
// ---------------------------------------------------------------------------
|
|
464
|
-
|
|
465
|
-
router.get('/view/:key(*)', sessRole('SuperAdmin'), async (req, res) => {
|
|
466
|
-
try {
|
|
467
|
-
const bucketName = req.activeBucket;
|
|
468
|
-
let keyToUse;
|
|
469
|
-
try {
|
|
470
|
-
keyToUse = ensureKeyHasAppPrefix(req.params.key);
|
|
471
|
-
} catch (e) {
|
|
472
|
-
return res.status(400).json({ message: e.message });
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: false });
|
|
476
|
-
|
|
477
|
-
} catch (error) {
|
|
478
|
-
console.error("Error viewing file:", error);
|
|
479
|
-
if (error.name === 'NoSuchKey' || error.name === 'NotFound') {
|
|
480
|
-
res.status(404).json({ message: "File not found", key: req.params.key });
|
|
481
|
-
} else if (error.name === 'AccessDenied') {
|
|
482
|
-
res.status(403).json({ message: "Access denied" });
|
|
483
|
-
} else {
|
|
484
|
-
res.status(500).json({ message: "View failed", error: error.message });
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
if (mbkbucketVar?.publiView_enabled) {
|
|
490
|
-
router.get('/p_view/:key(*)', pviewRateLimit, pviewSecurity, async (req, res) => {
|
|
491
|
-
try {
|
|
492
|
-
const bucketName = resolveBucketName();
|
|
493
|
-
const key = req.params.key;
|
|
494
|
-
|
|
495
|
-
// No directory indexing — reject folder-like or empty keys
|
|
496
|
-
if (!key || key.endsWith('/')) {
|
|
497
|
-
return res.status(403).json({ message: 'Directory indexing is not allowed' });
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
let keyToUse;
|
|
501
|
-
try {
|
|
502
|
-
keyToUse = ensureKeyHasAppPrefix(key);
|
|
503
|
-
} catch (e) {
|
|
504
|
-
return res.status(400).json({ message: e.message });
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// Determine pview_b: check bucket config first, then env var
|
|
508
|
-
let p_view_inline = !!mbkbucketVar?.p_view_inline;
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if (!p_view_inline) {
|
|
512
|
-
// Signed URL mode — redirect to a pre-signed S3 URL (1 hour expiry)
|
|
513
|
-
try {
|
|
514
|
-
const signed = await generateSignedUrl(keyToUse, 'getObject', 3600, bucketName);
|
|
515
|
-
return res.redirect(302, signed.url);
|
|
516
|
-
} catch (err) {
|
|
517
|
-
console.error('[mbkbucket] p_view signed URL error:', err);
|
|
518
|
-
return res.status(500).json({ message: 'Failed to generate access URL' });
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Direct serve mode — stream file inline (with noindex header)
|
|
523
|
-
await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: true });
|
|
524
|
-
} catch (error) {
|
|
525
|
-
console.error('[mbkbucket] p_view error:', error);
|
|
526
|
-
res.status(500).json({ message: 'Internal server error', error: error.message });
|
|
527
|
-
}
|
|
528
|
-
});
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
export default router;
|
|
File without changes
|