ep_media_upload 0.2.4 → 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/LICENSE +191 -191
- package/README.md +91 -97
- package/ep.json +20 -20
- package/index.js +567 -561
- package/locales/en.json +12 -12
- package/package.json +56 -40
- package/static/css/ep_media_upload.css +102 -100
- package/static/images/upload-file-svgrepo-com.svg +6 -2
- package/static/js/clientHooks.js +284 -284
- package/templates/uploadButton.ejs +8 -8
- package/templates/uploadModal.ejs +12 -12
package/index.js
CHANGED
|
@@ -1,561 +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, 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
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
logger.
|
|
307
|
-
return res.status(
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
if (!
|
|
333
|
-
return res.status(400).json({ error: '
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/* ------------- Extension
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
logger.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
logger.
|
|
446
|
-
return res.status(
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
logger.
|
|
552
|
-
|
|
553
|
-
//
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
};
|
|
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
|
+
};
|