ep_media_upload 0.2.3 → 0.2.5

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/index.js CHANGED
@@ -1,538 +1,567 @@
1
- 'use strict';
2
-
3
- const eejs = require('ep_etherpad-lite/node/eejs/');
4
- // Compat: Etherpad 2.4+ uses ESM for Settings. Support both CJS and ESM.
5
- const settingsModule = require('ep_etherpad-lite/node/utils/Settings');
6
- const settings = settingsModule.default || settingsModule;
7
- const { randomUUID } = require('crypto');
8
- const path = require('path');
9
- const url = require('url');
10
-
11
- // Security Manager for pad access verification
12
- let securityManager;
13
- try {
14
- securityManager = require('ep_etherpad-lite/node/db/SecurityManager');
15
- } catch (e) {
16
- console.warn('[ep_media_upload] SecurityManager not available');
17
- }
18
-
19
- // AWS SDK v3 for presigned URLs
20
- let S3Client, PutObjectCommand, GetObjectCommand, getSignedUrl;
21
- try {
22
- ({ S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3'));
23
- ({ getSignedUrl } = require('@aws-sdk/s3-request-presigner'));
24
- } catch (e) {
25
- console.warn('[ep_media_upload] AWS SDK not installed; s3_presigned storage will not work.');
26
- }
27
-
28
- // Simple logger
29
- const logger = {
30
- debug: console.debug.bind(console),
31
- info: console.info.bind(console),
32
- warn: console.warn.bind(console),
33
- error: console.error.bind(console),
34
- };
35
-
36
- // ============================================================================
37
- // Rate Limiter with Periodic Cleanup
38
- // ============================================================================
39
- const _presignRateStore = new Map();
40
- const PRESIGN_RATE_WINDOW_MS = 60 * 1000; // 1 minute
41
- const PRESIGN_RATE_MAX = 30; // max 30 presigns per IP per min
42
- const RATE_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
43
-
44
- // Periodic cleanup to prevent memory leak from stale IPs
45
- setInterval(() => {
46
- const now = Date.now();
47
- for (const [ip, stamps] of _presignRateStore.entries()) {
48
- const validStamps = stamps.filter((t) => t > now - PRESIGN_RATE_WINDOW_MS);
49
- if (validStamps.length === 0) {
50
- _presignRateStore.delete(ip);
51
- } else {
52
- _presignRateStore.set(ip, validStamps);
53
- }
54
- }
55
- }, RATE_CLEANUP_INTERVAL_MS).unref(); // unref() so it doesn't prevent process exit
56
-
57
- // Utility: basic per-IP sliding-window rate limit
58
- const _rateLimitCheck = (ip) => {
59
- const now = Date.now();
60
- let stamps = _presignRateStore.get(ip) || [];
61
- stamps = stamps.filter((t) => t > now - PRESIGN_RATE_WINDOW_MS);
62
- if (stamps.length >= PRESIGN_RATE_MAX) return false;
63
- stamps.push(now);
64
- _presignRateStore.set(ip, stamps);
65
- return true;
66
- };
67
-
68
- // ============================================================================
69
- // Input Validation Helpers
70
- // ============================================================================
71
-
72
- /**
73
- * Validate padId to prevent path traversal and injection attacks.
74
- * Returns true if valid, false if invalid.
75
- *
76
- * Etherpad pad IDs can contain various characters including:
77
- * - Alphanumeric, hyphens, underscores
78
- * - Dots and colons (common in pad names)
79
- * - $ (for group pads, e.g., g.xxxxxxxx$padName)
80
- *
81
- * We use a blocklist approach to reject only dangerous patterns.
82
- */
83
- const isValidPadId = (padId) => {
84
- if (!padId || typeof padId !== 'string') return false;
85
- if (padId.length === 0 || padId.length > 500) return false; // Reasonable length limits
86
- // Reject path traversal sequences
87
- if (padId.includes('..')) return false;
88
- // Reject null bytes
89
- if (padId.includes('\0')) return false;
90
- // Reject slashes (forward and back) to prevent path manipulation
91
- if (padId.includes('/') || padId.includes('\\')) return false;
92
- // Reject control characters (ASCII 0-31)
93
- if (/[\x00-\x1f]/.test(padId)) return false;
94
- return true;
95
- };
96
-
97
- /**
98
- * Validate filename extension.
99
- * Returns the extension (without dot, lowercase) or null if invalid.
100
- */
101
- const getValidExtension = (filename) => {
102
- if (!filename || typeof filename !== 'string') return null;
103
- const ext = path.extname(filename);
104
- if (!ext || ext === '.') return null; // No extension or just a dot
105
- return ext.slice(1).toLowerCase(); // Remove leading dot
106
- };
107
-
108
- /**
109
- * MIME type to extension mapping for validation.
110
- * Maps file extensions to their valid MIME types.
111
- */
112
- const EXTENSION_MIME_MAP = {
113
- // Documents
114
- pdf: ['application/pdf'],
115
- doc: ['application/msword'],
116
- docx: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
117
- xls: ['application/vnd.ms-excel'],
118
- xlsx: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
119
- ppt: ['application/vnd.ms-powerpoint'],
120
- pptx: ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
121
- txt: ['text/plain'],
122
- rtf: ['application/rtf', 'text/rtf'],
123
- csv: ['text/csv', 'text/plain', 'application/csv'],
124
-
125
- // Images
126
- jpg: ['image/jpeg'],
127
- jpeg: ['image/jpeg'],
128
- png: ['image/png'],
129
- gif: ['image/gif'],
130
- webp: ['image/webp'],
131
- bmp: ['image/bmp'],
132
- svg: ['image/svg+xml'],
133
-
134
- // Audio
135
- mp3: ['audio/mpeg', 'audio/mp3'],
136
- wav: ['audio/wav', 'audio/wave', 'audio/x-wav', 'audio/vnd.wave'],
137
- ogg: ['audio/ogg'],
138
- m4a: ['audio/mp4', 'audio/x-m4a'],
139
- flac: ['audio/flac'],
140
-
141
- // Video
142
- mp4: ['video/mp4'],
143
- mov: ['video/quicktime'],
144
- avi: ['video/x-msvideo'],
145
- mkv: ['video/x-matroska'],
146
- webm: ['video/webm'],
147
-
148
- // Archives
149
- zip: ['application/zip', 'application/x-zip-compressed'],
150
- rar: ['application/vnd.rar', 'application/x-rar-compressed'],
151
- '7z': ['application/x-7z-compressed'],
152
- tar: ['application/x-tar'],
153
- gz: ['application/gzip', 'application/x-gzip'],
154
- };
155
-
156
- /**
157
- * Validate that the MIME type matches the file extension.
158
- * Returns true if valid, false if mismatch detected.
159
- * If extension is not in our map, we allow it (permissive for unknown types).
160
- */
161
- const isValidMimeForExtension = (extension, mimeType) => {
162
- if (!extension || !mimeType) return false;
163
-
164
- const allowedMimes = EXTENSION_MIME_MAP[extension.toLowerCase()];
165
-
166
- // If we don't have a mapping for this extension, allow any MIME type
167
- // (permissive approach for uncommon file types)
168
- if (!allowedMimes) return true;
169
-
170
- // Check if the provided MIME type matches any allowed MIME for this extension
171
- const normalizedMime = mimeType.toLowerCase().split(';')[0].trim(); // Handle "text/plain; charset=utf-8"
172
- return allowedMimes.some(allowed => allowed === normalizedMime);
173
- };
174
-
175
- /**
176
- * Validate file ID for download endpoint.
177
- * File ID format: UUID (with hyphens) + dot + extension
178
- * Example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"
179
- * Returns true if valid, false if invalid.
180
- */
181
- const isValidFileId = (fileId) => {
182
- if (!fileId || typeof fileId !== 'string') return false;
183
- if (fileId.length > 100) return false; // UUID (36) + dot (1) + extension (max ~10)
184
- // Reject path traversal and dangerous characters
185
- if (fileId.includes('..') || fileId.includes('/') || fileId.includes('\\')) return false;
186
- if (fileId.includes('\0')) return false;
187
- // Must match: UUID format (with hyphens) + dot + alphanumeric extension
188
- // UUID: 8-4-4-4-12 hex chars with hyphens = 36 chars
189
- if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.[a-z0-9]+$/i.test(fileId)) return false;
190
- return true;
191
- };
192
-
193
- // ============================================================================
194
- // Hooks
195
- // ============================================================================
196
-
197
- /**
198
- * loadSettings hook
199
- * Sync ep_media_upload config into the runtime Settings singleton
200
- */
201
- exports.loadSettings = (hookName, args, cb) => {
202
- try {
203
- const runtimeSettings = settingsModule.default || settingsModule;
204
- if (args && args.settings && args.settings.ep_media_upload) {
205
- runtimeSettings.ep_media_upload = args.settings.ep_media_upload;
206
- }
207
- } catch (e) {
208
- console.warn('[ep_media_upload] Failed to sync settings:', e);
209
- }
210
- cb();
211
- };
212
-
213
- /**
214
- * clientVars hook
215
- * Exposes plugin settings to client code via clientVars
216
- */
217
- exports.clientVars = (hookName, args, cb) => {
218
- const pluginSettings = {
219
- storageType: 's3_presigned',
220
- };
221
-
222
- if (!settings.ep_media_upload) {
223
- settings.ep_media_upload = {};
224
- }
225
-
226
- // Pass allowed file types
227
- if (settings.ep_media_upload.fileTypes) {
228
- pluginSettings.fileTypes = settings.ep_media_upload.fileTypes;
229
- }
230
-
231
- // Pass max file size
232
- if (settings.ep_media_upload.maxFileSize) {
233
- pluginSettings.maxFileSize = settings.ep_media_upload.maxFileSize;
234
- }
235
-
236
- return cb({ ep_media_upload: pluginSettings });
237
- };
238
-
239
- /**
240
- * eejsBlock_editbarMenuLeft hook
241
- * Inject toolbar button
242
- */
243
- exports.eejsBlock_editbarMenuLeft = (hookName, args, cb) => {
244
- if (args.renderContext.isReadOnly) return cb();
245
- args.content += eejs.require('ep_media_upload/templates/uploadButton.ejs');
246
- return cb();
247
- };
248
-
249
- /**
250
- * eejsBlock_body hook
251
- * Inject modal HTML and CSS
252
- */
253
- exports.eejsBlock_body = (hookName, args, cb) => {
254
- const modal = eejs.require('ep_media_upload/templates/uploadModal.ejs');
255
- args.content += modal;
256
- args.content += "<link href='../static/plugins/ep_media_upload/static/css/ep_media_upload.css' rel='stylesheet'>";
257
- return cb();
258
- };
259
-
260
- /**
261
- * expressCreateServer hook
262
- * Register the S3 presign and download endpoints
263
- */
264
- exports.expressCreateServer = (hookName, context) => {
265
- logger.info('[ep_media_upload] Registering presign endpoint');
266
-
267
- // Route: GET /p/:padId/pluginfw/ep_media_upload/s3_presign
268
- context.app.get('/p/:padId/pluginfw/ep_media_upload/s3_presign', async (req, res) => {
269
- const { padId } = req.params;
270
-
271
- /* ------------------ Validate padId ------------------ */
272
- if (!isValidPadId(padId)) {
273
- return res.status(400).json({ error: 'Invalid pad ID' });
274
- }
275
-
276
- /* ------------------ Pad Access Verification ------------------ */
277
- // Use Etherpad's SecurityManager to verify user has access to this pad
278
- // SECURITY: Fail closed - if SecurityManager is unavailable, deny all requests
279
- if (!securityManager) {
280
- logger.error('[ep_media_upload] SECURITY: SecurityManager unavailable - denying upload request. This should not happen in a properly configured Etherpad instance.');
281
- return res.status(500).json({ error: 'Security module unavailable' });
282
- }
283
-
284
- // Get client IP for rate limiting and audit logging
285
- const clientIp = req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress || 'unknown';
286
-
287
- let authorId = 'unknown';
288
- try {
289
- const sessionCookie = req.cookies?.sessionID || null;
290
- const token = req.cookies?.token || null;
291
- const user = req.session?.user || null;
292
-
293
- const accessResult = await securityManager.checkAccess(padId, sessionCookie, token, user);
294
- if (accessResult.accessStatus !== 'grant') {
295
- logger.warn(`[ep_media_upload] UPLOAD_DENIED: ip="${clientIp}" pad="${padId}" reason="access_denied"`);
296
- return res.status(403).json({ error: 'Access denied to this pad' });
297
- }
298
- authorId = accessResult.authorID || 'unknown';
299
- } catch (authErr) {
300
- logger.error('[ep_media_upload] Access check error:', authErr);
301
- return res.status(500).json({ error: 'Access verification failed' });
302
- }
303
-
304
- /* ------------------ Rate limiting --------------------- */
305
- if (!_rateLimitCheck(clientIp)) {
306
- logger.warn(`[ep_media_upload] UPLOAD_RATE_LIMITED: ip="${clientIp}" pad="${padId}"`);
307
- return res.status(429).json({ error: 'Too many presign requests' });
308
- }
309
-
310
- try {
311
- const storageCfg = settings.ep_media_upload && settings.ep_media_upload.storage;
312
- if (!storageCfg || storageCfg.type !== 's3_presigned') {
313
- return res.status(400).json({ error: 's3_presigned storage not configured' });
314
- }
315
-
316
- if (!S3Client || !PutObjectCommand || !getSignedUrl) {
317
- return res.status(500).json({ error: 'AWS SDK not available on server' });
318
- }
319
-
320
- const { bucket, region, expires, keyPrefix } = storageCfg;
321
- if (!bucket || !region) {
322
- return res.status(500).json({ error: 'Invalid S3 configuration: missing bucket or region' });
323
- }
324
-
325
- const { name, type } = req.query;
326
- if (!name || !type) {
327
- return res.status(400).json({ error: 'Missing name or type query parameters' });
328
- }
329
-
330
- /* ------------- Extension validation ------------ */
331
- const extName = getValidExtension(name);
332
- if (!extName) {
333
- return res.status(400).json({ error: 'Invalid filename: missing extension' });
334
- }
335
-
336
- /* ------------- Extension allow-list ------------ */
337
- if (settings.ep_media_upload && settings.ep_media_upload.fileTypes && Array.isArray(settings.ep_media_upload.fileTypes)) {
338
- const allowedExts = settings.ep_media_upload.fileTypes;
339
- if (!allowedExts.includes(extName)) {
340
- return res.status(400).json({ error: 'File type not allowed' });
341
- }
342
- }
343
-
344
- /* ------------- MIME type validation ------------ */
345
- // Prevent MIME type spoofing (e.g., uploading .txt with Content-Type: text/html)
346
- if (!isValidMimeForExtension(extName, type)) {
347
- logger.warn(`[ep_media_upload] MIME mismatch: ext=${extName}, type=${type}`);
348
- return res.status(400).json({ error: 'MIME type does not match file extension' });
349
- }
350
-
351
- // Build S3 key with optional prefix for path-based routing (e.g., CloudFront origins)
352
- const prefix = keyPrefix || '';
353
- const safeExt = `.${extName}`;
354
- const objectPath = `${padId}/${randomUUID()}${safeExt}`; // e.g., "myPad/abc123.pdf"
355
- const key = `${prefix}${objectPath}`; // e.g., "uploads/myPad/abc123.pdf"
356
-
357
- const s3Client = new S3Client({ region }); // credentials from env / IAM role
358
-
359
- // Extract original filename for Content-Disposition header
360
- // This ensures files download with their original name instead of the UUID
361
- const originalFilename = path.basename(name);
362
- const safeFilename = originalFilename.replace(/[^\w\-_.]/g, '_'); // Sanitize for header
363
- const contentDisposition = `attachment; filename="${safeFilename}"`;
364
-
365
- const putCommand = new PutObjectCommand({
366
- Bucket: bucket,
367
- Key: key,
368
- ContentType: type,
369
- // Force download instead of opening in browser
370
- ContentDisposition: contentDisposition,
371
- });
372
-
373
- const signedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: expires || 600 });
374
-
375
- // Build secure download URL (relative path that goes through our auth-protected endpoint)
376
- // Using query parameter for fileId to ensure Express 4/5 compatibility (path params don't handle dots well in Express 5)
377
- const fileId = path.basename(key); // e.g., "abc123-def456.pdf"
378
- const downloadUrl = `/p/${encodeURIComponent(padId)}/pluginfw/ep_media_upload/download?file=${encodeURIComponent(fileId)}`;
379
-
380
- // Log upload request for audit trail
381
- // Note: Never log tokens or session cookies - only non-sensitive identifiers
382
- const username = req.session?.user?.username || 'anonymous';
383
- logger.info(`[ep_media_upload] UPLOAD: author="${authorId}" user="${username}" ip="${clientIp}" pad="${padId}" file="${originalFilename}" s3key="${key}"`);
384
-
385
- // Return downloadUrl for hyperlink insertion (authenticated download endpoint)
386
- // Also return signedUrl for the actual S3 upload and contentDisposition for PUT headers
387
- return res.json({ signedUrl, downloadUrl, contentDisposition });
388
- } catch (err) {
389
- logger.error('[ep_media_upload] S3 presign error', err);
390
- return res.status(500).json({ error: 'Failed to generate presigned URL' });
391
- }
392
- });
393
-
394
- // ============================================================================
395
- // Download Endpoint - Secure file access via presigned GET URL redirect
396
- // ============================================================================
397
- // Route: GET /p/:padId/pluginfw/ep_media_upload/download?file=<fileId>
398
- // Using query parameter for fileId to ensure Express 4/5 compatibility
399
- logger.info('[ep_media_upload] Registering download endpoint');
400
-
401
- context.app.get('/p/:padId/pluginfw/ep_media_upload/download', async (req, res) => {
402
- const { padId } = req.params;
403
- const fileId = req.query.file;
404
-
405
- /* ------------------ Validate padId ------------------ */
406
- if (!isValidPadId(padId)) {
407
- return res.status(400).json({ error: 'Invalid pad ID' });
408
- }
409
-
410
- /* ------------------ Validate fileId ------------------ */
411
- if (!isValidFileId(fileId)) {
412
- return res.status(400).json({ error: 'Invalid file ID' });
413
- }
414
-
415
- /* ------------------ Pad Access Verification ------------------ */
416
- // Use Etherpad's SecurityManager to verify user has access to this pad
417
- // SECURITY: Fail closed - if SecurityManager is unavailable, deny all requests
418
- if (!securityManager) {
419
- logger.error('[ep_media_upload] SECURITY: SecurityManager unavailable - denying download request. This should not happen in a properly configured Etherpad instance.');
420
- return res.status(500).json({ error: 'Security module unavailable' });
421
- }
422
-
423
- // Get client IP for rate limiting and audit logging
424
- const clientIp = req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress || 'unknown';
425
-
426
- let authorId = 'unknown';
427
- try {
428
- const sessionCookie = req.cookies?.sessionID || null;
429
- const token = req.cookies?.token || null;
430
- const user = req.session?.user || null;
431
-
432
- const accessResult = await securityManager.checkAccess(padId, sessionCookie, token, user);
433
- if (accessResult.accessStatus !== 'grant') {
434
- logger.warn(`[ep_media_upload] DOWNLOAD_DENIED: ip="${clientIp}" pad="${padId}" file="${fileId}" reason="access_denied"`);
435
- return res.status(403).json({ error: 'Access denied to this pad' });
436
- }
437
- authorId = accessResult.authorID || 'unknown';
438
- } catch (authErr) {
439
- logger.error('[ep_media_upload] Download access check error:', authErr);
440
- return res.status(500).json({ error: 'Access verification failed' });
441
- }
442
-
443
- /* ------------------ Rate limiting --------------------- */
444
- if (!_rateLimitCheck(clientIp)) {
445
- logger.warn(`[ep_media_upload] DOWNLOAD_RATE_LIMITED: ip="${clientIp}" pad="${padId}" file="${fileId}"`);
446
- return res.status(429).json({ error: 'Too many download requests' });
447
- }
448
-
449
- try {
450
- const storageCfg = settings.ep_media_upload && settings.ep_media_upload.storage;
451
- if (!storageCfg || storageCfg.type !== 's3_presigned') {
452
- return res.status(400).json({ error: 's3_presigned storage not configured' });
453
- }
454
-
455
- if (!S3Client || !GetObjectCommand || !getSignedUrl) {
456
- return res.status(500).json({ error: 'AWS SDK not available on server' });
457
- }
458
-
459
- const { bucket, region, keyPrefix, downloadExpires } = storageCfg;
460
- if (!bucket || !region) {
461
- return res.status(500).json({ error: 'Invalid S3 configuration: missing bucket or region' });
462
- }
463
-
464
- // Construct S3 key from padId and fileId
465
- // Key format: keyPrefix + padId + "/" + fileId
466
- // e.g., "uploads/myPad/abc123-def456.pdf"
467
- const prefix = keyPrefix || '';
468
- const key = `${prefix}${padId}/${fileId}`;
469
-
470
- // Extract file extension to determine inline vs attachment disposition
471
- const fileExtension = getValidExtension(fileId);
472
-
473
- // Get inlineExtensions from config (extensions that should open in browser)
474
- // Default behavior is download (attachment) for all files
475
- const inlineExtensions = settings.ep_media_upload?.inlineExtensions || [];
476
- const shouldOpenInline = fileExtension &&
477
- Array.isArray(inlineExtensions) &&
478
- inlineExtensions.map(e => e.toLowerCase()).includes(fileExtension.toLowerCase());
479
-
480
- // Determine Content-Disposition based on extension config
481
- // Extract filename for Content-Disposition header (UUID.ext -> use as filename)
482
- const filename = fileId.replace(/[^\w\-_.]/g, '_'); // Sanitize for header
483
- const disposition = shouldOpenInline
484
- ? `inline; filename="${filename}"`
485
- : `attachment; filename="${filename}"`;
486
-
487
- // Map extensions to canonical MIME types for consistent browser playback
488
- const EXTENSION_CONTENT_TYPE = {
489
- mp3: 'audio/mpeg',
490
- wav: 'audio/wav',
491
- mp4: 'video/mp4',
492
- mov: 'video/quicktime',
493
- webm: 'video/webm',
494
- ogg: 'audio/ogg',
495
- m4a: 'audio/mp4',
496
- pdf: 'application/pdf',
497
- };
498
-
499
- // Generate presigned GET URL with short expiry
500
- // Use ResponseContentDisposition and ResponseContentType to override stored headers
501
- const s3Client = new S3Client({ region });
502
- const commandParams = {
503
- Bucket: bucket,
504
- Key: key,
505
- ResponseContentDisposition: disposition,
506
- };
507
-
508
- // Set canonical Content-Type for inline extensions to ensure browser compatibility
509
- if (shouldOpenInline && fileExtension && EXTENSION_CONTENT_TYPE[fileExtension.toLowerCase()]) {
510
- commandParams.ResponseContentType = EXTENSION_CONTENT_TYPE[fileExtension.toLowerCase()];
511
- }
512
-
513
- const getCommand = new GetObjectCommand(commandParams);
514
-
515
- // Use downloadExpires from config, default to 300 seconds (5 minutes)
516
- const expiresIn = downloadExpires || 300;
517
- const presignedGetUrl = await getSignedUrl(s3Client, getCommand, { expiresIn });
518
-
519
- // Log download request for audit trail
520
- const username = req.session?.user?.username || 'anonymous';
521
- const dispositionType = shouldOpenInline ? 'inline' : 'attachment';
522
- logger.info(`[ep_media_upload] DOWNLOAD: author="${authorId}" user="${username}" ip="${clientIp}" pad="${padId}" file="${fileId}" disposition="${dispositionType}"`);
523
-
524
- // Redirect to the presigned URL
525
- return res.redirect(302, presignedGetUrl);
526
-
527
- } catch (err) {
528
- logger.error('[ep_media_upload] Download presign error:', err);
529
-
530
- // Check if this is a "NoSuchKey" error (file doesn't exist in S3)
531
- if (err.name === 'NoSuchKey' || err.Code === 'NoSuchKey') {
532
- return res.status(404).json({ error: 'File not found' });
533
- }
534
-
535
- return res.status(500).json({ error: 'Failed to generate download URL' });
536
- }
537
- });
538
- };
1
+ 'use strict';
2
+
3
+ const eejs = require('ep_etherpad-lite/node/eejs/');
4
+ // Compat: Etherpad 2.4+ uses ESM for Settings. Support both CJS and ESM.
5
+ const settingsModule = require('ep_etherpad-lite/node/utils/Settings');
6
+ const settings = settingsModule.default || settingsModule;
7
+ const { randomUUID } = require('crypto');
8
+ const path = require('path');
9
+ const url = require('url');
10
+
11
+ // Security Manager for pad access verification
12
+ let securityManager;
13
+ try {
14
+ securityManager = require('ep_etherpad-lite/node/db/SecurityManager');
15
+ } catch (e) {
16
+ console.warn('[ep_media_upload] SecurityManager not available');
17
+ }
18
+
19
+ // AWS SDK v3 for presigned URLs
20
+ let S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand, getSignedUrl;
21
+ try {
22
+ ({ S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'));
23
+ ({ getSignedUrl } = require('@aws-sdk/s3-request-presigner'));
24
+ } catch (e) {
25
+ console.warn('[ep_media_upload] AWS SDK not installed; s3_presigned storage will not work.');
26
+ }
27
+
28
+ // Simple logger
29
+ const logger = {
30
+ debug: console.debug.bind(console),
31
+ info: console.info.bind(console),
32
+ warn: console.warn.bind(console),
33
+ error: console.error.bind(console),
34
+ };
35
+
36
+ // ============================================================================
37
+ // Rate Limiter with Periodic Cleanup
38
+ // ============================================================================
39
+ const _presignRateStore = new Map();
40
+ const PRESIGN_RATE_WINDOW_MS = 60 * 1000; // 1 minute
41
+ const PRESIGN_RATE_MAX = 30; // max 30 presigns per IP per min
42
+ const RATE_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
43
+
44
+ // Periodic cleanup to prevent memory leak from stale IPs
45
+ setInterval(() => {
46
+ const now = Date.now();
47
+ for (const [ip, stamps] of _presignRateStore.entries()) {
48
+ const validStamps = stamps.filter((t) => t > now - PRESIGN_RATE_WINDOW_MS);
49
+ if (validStamps.length === 0) {
50
+ _presignRateStore.delete(ip);
51
+ } else {
52
+ _presignRateStore.set(ip, validStamps);
53
+ }
54
+ }
55
+ }, RATE_CLEANUP_INTERVAL_MS).unref(); // unref() so it doesn't prevent process exit
56
+
57
+ // Utility: basic per-IP sliding-window rate limit
58
+ const _rateLimitCheck = (ip) => {
59
+ const now = Date.now();
60
+ let stamps = _presignRateStore.get(ip) || [];
61
+ stamps = stamps.filter((t) => t > now - PRESIGN_RATE_WINDOW_MS);
62
+ if (stamps.length >= PRESIGN_RATE_MAX) return false;
63
+ stamps.push(now);
64
+ _presignRateStore.set(ip, stamps);
65
+ return true;
66
+ };
67
+
68
+ // ============================================================================
69
+ // Input Validation Helpers
70
+ // ============================================================================
71
+
72
+ /**
73
+ * Validate padId to prevent path traversal and injection attacks.
74
+ * Returns true if valid, false if invalid.
75
+ *
76
+ * Etherpad pad IDs can contain various characters including:
77
+ * - Alphanumeric, hyphens, underscores
78
+ * - Dots and colons (common in pad names)
79
+ * - $ (for group pads, e.g., g.xxxxxxxx$padName)
80
+ *
81
+ * We use a blocklist approach to reject only dangerous patterns.
82
+ */
83
+ const isValidPadId = (padId) => {
84
+ if (!padId || typeof padId !== 'string') return false;
85
+ if (padId.length === 0 || padId.length > 500) return false; // Reasonable length limits
86
+ // Reject path traversal sequences
87
+ if (padId.includes('..')) return false;
88
+ // Reject null bytes
89
+ if (padId.includes('\0')) return false;
90
+ // Reject slashes (forward and back) to prevent path manipulation
91
+ if (padId.includes('/') || padId.includes('\\')) return false;
92
+ // Reject control characters (ASCII 0-31)
93
+ if (/[\x00-\x1f]/.test(padId)) return false;
94
+ return true;
95
+ };
96
+
97
+ /**
98
+ * Validate filename extension.
99
+ * Returns the extension (without dot, lowercase) or null if invalid.
100
+ */
101
+ const getValidExtension = (filename) => {
102
+ if (!filename || typeof filename !== 'string') return null;
103
+ const ext = path.extname(filename);
104
+ if (!ext || ext === '.') return null; // No extension or just a dot
105
+ return ext.slice(1).toLowerCase(); // Remove leading dot
106
+ };
107
+
108
+ /**
109
+ * MIME type to extension mapping for validation.
110
+ * Maps file extensions to their valid MIME types.
111
+ */
112
+ const EXTENSION_MIME_MAP = {
113
+ // Documents
114
+ pdf: ['application/pdf'],
115
+ doc: ['application/msword'],
116
+ docx: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
117
+ xls: ['application/vnd.ms-excel'],
118
+ xlsx: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
119
+ ppt: ['application/vnd.ms-powerpoint'],
120
+ pptx: ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
121
+ txt: ['text/plain'],
122
+ rtf: ['application/rtf', 'text/rtf'],
123
+ csv: ['text/csv', 'text/plain', 'application/csv'],
124
+
125
+ // Images
126
+ jpg: ['image/jpeg'],
127
+ jpeg: ['image/jpeg'],
128
+ png: ['image/png'],
129
+ gif: ['image/gif'],
130
+ webp: ['image/webp'],
131
+ bmp: ['image/bmp'],
132
+ svg: ['image/svg+xml'],
133
+
134
+ // Audio
135
+ mp3: ['audio/mpeg', 'audio/mp3'],
136
+ wav: ['audio/wav', 'audio/wave', 'audio/x-wav', 'audio/vnd.wave'],
137
+ ogg: ['audio/ogg'],
138
+ m4a: ['audio/mp4', 'audio/x-m4a'],
139
+ flac: ['audio/flac'],
140
+
141
+ // Video
142
+ mp4: ['video/mp4'],
143
+ mov: ['video/quicktime'],
144
+ avi: ['video/x-msvideo'],
145
+ mkv: ['video/x-matroska'],
146
+ webm: ['video/webm'],
147
+
148
+ // Archives
149
+ zip: ['application/zip', 'application/x-zip-compressed'],
150
+ rar: ['application/vnd.rar', 'application/x-rar-compressed'],
151
+ '7z': ['application/x-7z-compressed'],
152
+ tar: ['application/x-tar'],
153
+ gz: ['application/gzip', 'application/x-gzip'],
154
+ };
155
+
156
+ /**
157
+ * Validate that the MIME type matches the file extension.
158
+ * Returns true if valid, false if mismatch detected.
159
+ * If extension is not in our map, we allow it (permissive for unknown types).
160
+ */
161
+ const isValidMimeForExtension = (extension, mimeType) => {
162
+ if (!extension || !mimeType) return false;
163
+
164
+ const allowedMimes = EXTENSION_MIME_MAP[extension.toLowerCase()];
165
+
166
+ // If we don't have a mapping for this extension, allow any MIME type
167
+ // (permissive approach for uncommon file types)
168
+ if (!allowedMimes) return true;
169
+
170
+ // Check if the provided MIME type matches any allowed MIME for this extension
171
+ const normalizedMime = mimeType.toLowerCase().split(';')[0].trim(); // Handle "text/plain; charset=utf-8"
172
+ return allowedMimes.some(allowed => allowed === normalizedMime);
173
+ };
174
+
175
+ /**
176
+ * Validate file ID for download endpoint.
177
+ * File ID format: UUID (with hyphens) + dot + extension
178
+ * Example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"
179
+ * Returns true if valid, false if invalid.
180
+ */
181
+ const isValidFileId = (fileId) => {
182
+ if (!fileId || typeof fileId !== 'string') return false;
183
+ if (fileId.length > 100) return false; // UUID (36) + dot (1) + extension (max ~10)
184
+ // Reject path traversal and dangerous characters
185
+ if (fileId.includes('..') || fileId.includes('/') || fileId.includes('\\')) return false;
186
+ if (fileId.includes('\0')) return false;
187
+ // Must match: UUID format (with hyphens) + dot + alphanumeric extension
188
+ // UUID: 8-4-4-4-12 hex chars with hyphens = 36 chars
189
+ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.[a-z0-9]+$/i.test(fileId)) return false;
190
+ return true;
191
+ };
192
+
193
+ // Keep validation contracts directly testable without exporting runtime state,
194
+ // AWS clients, credentials, or registered routes.
195
+ Object.defineProperty(exports, '__testValidation', {
196
+ value: {getValidExtension, isValidFileId, isValidMimeForExtension, isValidPadId},
197
+ });
198
+
199
+ // ============================================================================
200
+ // Hooks
201
+ // ============================================================================
202
+
203
+ /**
204
+ * loadSettings hook
205
+ * Sync ep_media_upload config into the runtime Settings singleton
206
+ */
207
+ exports.loadSettings = (hookName, args, cb) => {
208
+ try {
209
+ const runtimeSettings = settingsModule.default || settingsModule;
210
+ if (args && args.settings && args.settings.ep_media_upload) {
211
+ runtimeSettings.ep_media_upload = args.settings.ep_media_upload;
212
+ }
213
+ } catch (e) {
214
+ console.warn('[ep_media_upload] Failed to sync settings:', e);
215
+ }
216
+ cb();
217
+ };
218
+
219
+ /**
220
+ * clientVars hook
221
+ * Exposes plugin settings to client code via clientVars
222
+ */
223
+ exports.clientVars = (hookName, args, cb) => {
224
+ const pluginSettings = {
225
+ storageType: 's3_presigned',
226
+ };
227
+
228
+ if (!settings.ep_media_upload) {
229
+ settings.ep_media_upload = {};
230
+ }
231
+
232
+ // Pass allowed file types
233
+ if (settings.ep_media_upload.fileTypes) {
234
+ pluginSettings.fileTypes = settings.ep_media_upload.fileTypes;
235
+ }
236
+
237
+ // Pass max file size
238
+ if (settings.ep_media_upload.maxFileSize) {
239
+ pluginSettings.maxFileSize = settings.ep_media_upload.maxFileSize;
240
+ }
241
+
242
+ return cb({ ep_media_upload: pluginSettings });
243
+ };
244
+
245
+ /**
246
+ * eejsBlock_editbarMenuLeft hook
247
+ * Inject toolbar button
248
+ */
249
+ exports.eejsBlock_editbarMenuLeft = (hookName, args, cb) => {
250
+ if (args.renderContext.isReadOnly) return cb();
251
+ args.content += eejs.require('ep_media_upload/templates/uploadButton.ejs');
252
+ return cb();
253
+ };
254
+
255
+ /**
256
+ * eejsBlock_body hook
257
+ * Inject modal HTML and CSS
258
+ */
259
+ exports.eejsBlock_body = (hookName, args, cb) => {
260
+ const modal = eejs.require('ep_media_upload/templates/uploadModal.ejs');
261
+ args.content += modal;
262
+ args.content += "<link href='../static/plugins/ep_media_upload/static/css/ep_media_upload.css' rel='stylesheet'>";
263
+ return cb();
264
+ };
265
+
266
+ /**
267
+ * expressCreateServer hook
268
+ * Register the S3 presign and download endpoints
269
+ */
270
+ exports.expressCreateServer = (hookName, context) => {
271
+ logger.info('[ep_media_upload] Registering presign endpoint');
272
+
273
+ // Route: GET /p/:padId/pluginfw/ep_media_upload/s3_presign
274
+ context.app.get('/p/:padId/pluginfw/ep_media_upload/s3_presign', async (req, res) => {
275
+ const { padId } = req.params;
276
+
277
+ /* ------------------ Validate padId ------------------ */
278
+ if (!isValidPadId(padId)) {
279
+ return res.status(400).json({ error: 'Invalid pad ID' });
280
+ }
281
+
282
+ /* ------------------ Pad Access Verification ------------------ */
283
+ // Use Etherpad's SecurityManager to verify user has access to this pad
284
+ // SECURITY: Fail closed - if SecurityManager is unavailable, deny all requests
285
+ if (!securityManager) {
286
+ logger.error('[ep_media_upload] SECURITY: SecurityManager unavailable - denying upload request. This should not happen in a properly configured Etherpad instance.');
287
+ return res.status(500).json({ error: 'Security module unavailable' });
288
+ }
289
+
290
+ // Get client IP for rate limiting and audit logging
291
+ const clientIp = req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress || 'unknown';
292
+
293
+ let authorId = 'unknown';
294
+ try {
295
+ const sessionCookie = req.cookies?.sessionID || null;
296
+ const token = req.cookies?.token || null;
297
+ const user = req.session?.user || null;
298
+
299
+ const accessResult = await securityManager.checkAccess(padId, sessionCookie, token, user);
300
+ if (accessResult.accessStatus !== 'grant') {
301
+ logger.warn(`[ep_media_upload] UPLOAD_DENIED: ip="${clientIp}" pad="${padId}" reason="access_denied"`);
302
+ return res.status(403).json({ error: 'Access denied to this pad' });
303
+ }
304
+ authorId = accessResult.authorID || 'unknown';
305
+ } catch (authErr) {
306
+ logger.error('[ep_media_upload] Access check error:', authErr);
307
+ return res.status(500).json({ error: 'Access verification failed' });
308
+ }
309
+
310
+ /* ------------------ Rate limiting --------------------- */
311
+ if (!_rateLimitCheck(clientIp)) {
312
+ logger.warn(`[ep_media_upload] UPLOAD_RATE_LIMITED: ip="${clientIp}" pad="${padId}"`);
313
+ return res.status(429).json({ error: 'Too many presign requests' });
314
+ }
315
+
316
+ try {
317
+ const storageCfg = settings.ep_media_upload && settings.ep_media_upload.storage;
318
+ if (!storageCfg || storageCfg.type !== 's3_presigned') {
319
+ return res.status(400).json({ error: 's3_presigned storage not configured' });
320
+ }
321
+
322
+ if (!S3Client || !PutObjectCommand || !getSignedUrl) {
323
+ return res.status(500).json({ error: 'AWS SDK not available on server' });
324
+ }
325
+
326
+ const { bucket, region, expires, keyPrefix } = storageCfg;
327
+ if (!bucket || !region) {
328
+ return res.status(500).json({ error: 'Invalid S3 configuration: missing bucket or region' });
329
+ }
330
+
331
+ const { name, type } = req.query;
332
+ if (!name || !type) {
333
+ return res.status(400).json({ error: 'Missing name or type query parameters' });
334
+ }
335
+
336
+ /* ------------- Extension validation ------------ */
337
+ const extName = getValidExtension(name);
338
+ if (!extName) {
339
+ return res.status(400).json({ error: 'Invalid filename: missing extension' });
340
+ }
341
+
342
+ /* ------------- Extension allow-list ------------ */
343
+ if (settings.ep_media_upload && settings.ep_media_upload.fileTypes && Array.isArray(settings.ep_media_upload.fileTypes)) {
344
+ const allowedExts = settings.ep_media_upload.fileTypes;
345
+ if (!allowedExts.includes(extName)) {
346
+ return res.status(400).json({ error: 'File type not allowed' });
347
+ }
348
+ }
349
+
350
+ /* ------------- MIME type validation ------------ */
351
+ // Prevent MIME type spoofing (e.g., uploading .txt with Content-Type: text/html)
352
+ if (!isValidMimeForExtension(extName, type)) {
353
+ logger.warn(`[ep_media_upload] MIME mismatch: ext=${extName}, type=${type}`);
354
+ return res.status(400).json({ error: 'MIME type does not match file extension' });
355
+ }
356
+
357
+ // Build S3 key with optional prefix for path-based routing (e.g., CloudFront origins)
358
+ const prefix = keyPrefix || '';
359
+ const safeExt = `.${extName}`;
360
+ const objectPath = `${padId}/${randomUUID()}${safeExt}`; // e.g., "myPad/abc123.pdf"
361
+ const key = `${prefix}${objectPath}`; // e.g., "uploads/myPad/abc123.pdf"
362
+
363
+ const s3Client = new S3Client({ region }); // credentials from env / IAM role
364
+
365
+ // Extract original filename for Content-Disposition header
366
+ // This ensures files download with their original name instead of the UUID
367
+ const originalFilename = path.basename(name);
368
+ const safeFilename = originalFilename.replace(/[^\w\-_.]/g, '_'); // Sanitize for header
369
+ const contentDisposition = `attachment; filename="${safeFilename}"`;
370
+
371
+ const putCommand = new PutObjectCommand({
372
+ Bucket: bucket,
373
+ Key: key,
374
+ ContentType: type,
375
+ // Force download instead of opening in browser
376
+ ContentDisposition: contentDisposition,
377
+ });
378
+
379
+ const signedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: expires || 600 });
380
+
381
+ // Build secure download URL (relative path that goes through our auth-protected endpoint)
382
+ // Using query parameter for fileId to ensure Express 4/5 compatibility (path params don't handle dots well in Express 5)
383
+ const fileId = path.basename(key); // e.g., "abc123-def456.pdf"
384
+ const downloadUrl = `/p/${encodeURIComponent(padId)}/pluginfw/ep_media_upload/download?file=${encodeURIComponent(fileId)}`;
385
+
386
+ // Log upload request for audit trail
387
+ // Note: Never log tokens or session cookies - only non-sensitive identifiers
388
+ const username = req.session?.user?.username || 'anonymous';
389
+ logger.info(`[ep_media_upload] UPLOAD: author="${authorId}" user="${username}" ip="${clientIp}" pad="${padId}" file="${originalFilename}" s3key="${key}"`);
390
+
391
+ // Return downloadUrl for hyperlink insertion (authenticated download endpoint)
392
+ // Also return signedUrl for the actual S3 upload and contentDisposition for PUT headers
393
+ return res.json({ signedUrl, downloadUrl, contentDisposition });
394
+ } catch (err) {
395
+ logger.error('[ep_media_upload] S3 presign error', err);
396
+ return res.status(500).json({ error: 'Failed to generate presigned URL' });
397
+ }
398
+ });
399
+
400
+ // ============================================================================
401
+ // Download Endpoint - Secure file access via presigned GET URL redirect
402
+ // ============================================================================
403
+ // Route: GET /p/:padId/pluginfw/ep_media_upload/download?file=<fileId>
404
+ // Using query parameter for fileId to ensure Express 4/5 compatibility
405
+ logger.info('[ep_media_upload] Registering download endpoint');
406
+
407
+ context.app.get('/p/:padId/pluginfw/ep_media_upload/download', async (req, res) => {
408
+ const { padId } = req.params;
409
+ const fileId = req.query.file;
410
+
411
+ /* ------------------ Validate padId ------------------ */
412
+ if (!isValidPadId(padId)) {
413
+ return res.status(400).json({ error: 'Invalid pad ID' });
414
+ }
415
+
416
+ /* ------------------ Validate fileId ------------------ */
417
+ if (!isValidFileId(fileId)) {
418
+ return res.status(400).json({ error: 'Invalid file ID' });
419
+ }
420
+
421
+ /* ------------------ Pad Access Verification ------------------ */
422
+ // Use Etherpad's SecurityManager to verify user has access to this pad
423
+ // SECURITY: Fail closed - if SecurityManager is unavailable, deny all requests
424
+ if (!securityManager) {
425
+ logger.error('[ep_media_upload] SECURITY: SecurityManager unavailable - denying download request. This should not happen in a properly configured Etherpad instance.');
426
+ return res.status(500).json({ error: 'Security module unavailable' });
427
+ }
428
+
429
+ // Get client IP for rate limiting and audit logging
430
+ const clientIp = req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress || 'unknown';
431
+
432
+ let authorId = 'unknown';
433
+ try {
434
+ const sessionCookie = req.cookies?.sessionID || null;
435
+ const token = req.cookies?.token || null;
436
+ const user = req.session?.user || null;
437
+
438
+ const accessResult = await securityManager.checkAccess(padId, sessionCookie, token, user);
439
+ if (accessResult.accessStatus !== 'grant') {
440
+ logger.warn(`[ep_media_upload] DOWNLOAD_DENIED: ip="${clientIp}" pad="${padId}" file="${fileId}" reason="access_denied"`);
441
+ return res.status(403).json({ error: 'Access denied to this pad' });
442
+ }
443
+ authorId = accessResult.authorID || 'unknown';
444
+ } catch (authErr) {
445
+ logger.error('[ep_media_upload] Download access check error:', authErr);
446
+ return res.status(500).json({ error: 'Access verification failed' });
447
+ }
448
+
449
+ /* ------------------ Rate limiting --------------------- */
450
+ if (!_rateLimitCheck(clientIp)) {
451
+ logger.warn(`[ep_media_upload] DOWNLOAD_RATE_LIMITED: ip="${clientIp}" pad="${padId}" file="${fileId}"`);
452
+ return res.status(429).json({ error: 'Too many download requests' });
453
+ }
454
+
455
+ try {
456
+ const storageCfg = settings.ep_media_upload && settings.ep_media_upload.storage;
457
+ if (!storageCfg || storageCfg.type !== 's3_presigned') {
458
+ return res.status(400).json({ error: 's3_presigned storage not configured' });
459
+ }
460
+
461
+ if (!S3Client || !GetObjectCommand || !getSignedUrl) {
462
+ return res.status(500).json({ error: 'AWS SDK not available on server' });
463
+ }
464
+
465
+ const { bucket, region, keyPrefix, downloadExpires } = storageCfg;
466
+ if (!bucket || !region) {
467
+ return res.status(500).json({ error: 'Invalid S3 configuration: missing bucket or region' });
468
+ }
469
+
470
+ // Construct S3 key from padId and fileId
471
+ // Key format: keyPrefix + padId + "/" + fileId
472
+ // e.g., "uploads/myPad/abc123-def456.pdf"
473
+ const prefix = keyPrefix || '';
474
+ const key = `${prefix}${padId}/${fileId}`;
475
+
476
+ // Extract file extension to determine inline vs attachment disposition
477
+ const fileExtension = getValidExtension(fileId);
478
+
479
+ // Get inlineExtensions from config (extensions that should open in browser)
480
+ // Default behavior is download (attachment) for all files
481
+ const inlineExtensions = settings.ep_media_upload?.inlineExtensions || [];
482
+ const shouldOpenInline = fileExtension &&
483
+ Array.isArray(inlineExtensions) &&
484
+ inlineExtensions.map(e => e.toLowerCase()).includes(fileExtension.toLowerCase());
485
+
486
+ // Try to retrieve original filename from S3 object metadata
487
+ // The original filename was stored in Content-Disposition during upload
488
+ const s3Client = new S3Client({ region });
489
+ let filename = fileId.replace(/[^\w\-_.]/g, '_'); // Default fallback to UUID-based name
490
+
491
+ try {
492
+ const headCommand = new HeadObjectCommand({ Bucket: bucket, Key: key });
493
+ const headResponse = await s3Client.send(headCommand);
494
+
495
+ // Parse original filename from stored Content-Disposition header
496
+ // Format: attachment; filename="original-name.pdf"
497
+ if (headResponse.ContentDisposition) {
498
+ const match = headResponse.ContentDisposition.match(/filename="([^"]+)"/);
499
+ if (match && match[1]) {
500
+ // Use the original filename, sanitize it for safety
501
+ filename = match[1].replace(/[^\w\-_.]/g, '_');
502
+ }
503
+ }
504
+ } catch (headErr) {
505
+ // If HeadObject fails (e.g., file doesn't exist), we'll catch it later
506
+ // or use the fallback filename. Log for debugging but don't fail yet.
507
+ if (headErr.name !== 'NotFound' && headErr.Code !== 'NoSuchKey') {
508
+ logger.warn(`[ep_media_upload] HeadObject warning for key="${key}": ${headErr.message}`);
509
+ }
510
+ }
511
+
512
+ // Determine Content-Disposition based on extension config
513
+ const disposition = shouldOpenInline
514
+ ? `inline; filename="${filename}"`
515
+ : `attachment; filename="${filename}"`;
516
+
517
+ // Map extensions to canonical MIME types for consistent browser playback
518
+ const EXTENSION_CONTENT_TYPE = {
519
+ mp3: 'audio/mpeg',
520
+ wav: 'audio/wav',
521
+ mp4: 'video/mp4',
522
+ mov: 'video/quicktime',
523
+ webm: 'video/webm',
524
+ ogg: 'audio/ogg',
525
+ m4a: 'audio/mp4',
526
+ pdf: 'application/pdf',
527
+ };
528
+
529
+ // Generate presigned GET URL with short expiry
530
+ // Use ResponseContentDisposition and ResponseContentType to override stored headers
531
+ const commandParams = {
532
+ Bucket: bucket,
533
+ Key: key,
534
+ ResponseContentDisposition: disposition,
535
+ };
536
+
537
+ // Set canonical Content-Type for inline extensions to ensure browser compatibility
538
+ if (shouldOpenInline && fileExtension && EXTENSION_CONTENT_TYPE[fileExtension.toLowerCase()]) {
539
+ commandParams.ResponseContentType = EXTENSION_CONTENT_TYPE[fileExtension.toLowerCase()];
540
+ }
541
+
542
+ const getCommand = new GetObjectCommand(commandParams);
543
+
544
+ // Use downloadExpires from config, default to 300 seconds (5 minutes)
545
+ const expiresIn = downloadExpires || 300;
546
+ const presignedGetUrl = await getSignedUrl(s3Client, getCommand, { expiresIn });
547
+
548
+ // Log download request for audit trail
549
+ const username = req.session?.user?.username || 'anonymous';
550
+ const dispositionType = shouldOpenInline ? 'inline' : 'attachment';
551
+ logger.info(`[ep_media_upload] DOWNLOAD: author="${authorId}" user="${username}" ip="${clientIp}" pad="${padId}" file="${fileId}" disposition="${dispositionType}"`);
552
+
553
+ // Redirect to the presigned URL
554
+ return res.redirect(302, presignedGetUrl);
555
+
556
+ } catch (err) {
557
+ logger.error('[ep_media_upload] Download presign error:', err);
558
+
559
+ // Check if this is a "NoSuchKey" error (file doesn't exist in S3)
560
+ if (err.name === 'NoSuchKey' || err.Code === 'NoSuchKey') {
561
+ return res.status(404).json({ error: 'File not found' });
562
+ }
563
+
564
+ return res.status(500).json({ error: 'Failed to generate download URL' });
565
+ }
566
+ });
567
+ };