ep_media_upload 0.2.0 → 0.2.2
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/README.md +4 -2
- package/index.js +41 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,8 +27,9 @@ Etherpad plugin for secure file uploads via S3 presigned URLs.
|
|
|
27
27
|
"expires": 900,
|
|
28
28
|
"downloadExpires": 300
|
|
29
29
|
},
|
|
30
|
-
"fileTypes": ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "zip"],
|
|
31
|
-
"maxFileSize": 52428800
|
|
30
|
+
"fileTypes": ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "zip", "mp3", "mp4", "wav", "mov"],
|
|
31
|
+
"maxFileSize": 52428800,
|
|
32
|
+
"inlineExtensions": ["mp3", "mp4", "wav", "mov", "webm", "ogg"]
|
|
32
33
|
}
|
|
33
34
|
```
|
|
34
35
|
|
|
@@ -49,6 +50,7 @@ Etherpad plugin for secure file uploads via S3 presigned URLs.
|
|
|
49
50
|
|--------|----------|---------|-------------|
|
|
50
51
|
| `fileTypes` | No | all | Array of allowed extensions (without dots) |
|
|
51
52
|
| `maxFileSize` | No | unlimited | Max file size in bytes |
|
|
53
|
+
| `inlineExtensions` | No | `[]` | Extensions to open inline in browser (streaming). Files not in this list will download. |
|
|
52
54
|
|
|
53
55
|
### Environment Variables
|
|
54
56
|
|
package/index.js
CHANGED
|
@@ -133,7 +133,7 @@ const EXTENSION_MIME_MAP = {
|
|
|
133
133
|
|
|
134
134
|
// Audio
|
|
135
135
|
mp3: ['audio/mpeg', 'audio/mp3'],
|
|
136
|
-
wav: ['audio/wav', 'audio/wave', 'audio/x-wav'],
|
|
136
|
+
wav: ['audio/wav', 'audio/wave', 'audio/x-wav', 'audio/vnd.wave'],
|
|
137
137
|
ogg: ['audio/ogg'],
|
|
138
138
|
m4a: ['audio/mp4', 'audio/x-m4a'],
|
|
139
139
|
flac: ['audio/flac'],
|
|
@@ -281,6 +281,10 @@ exports.expressCreateServer = (hookName, context) => {
|
|
|
281
281
|
return res.status(500).json({ error: 'Security module unavailable' });
|
|
282
282
|
}
|
|
283
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';
|
|
284
288
|
try {
|
|
285
289
|
const sessionCookie = req.cookies?.sessionID || null;
|
|
286
290
|
const token = req.cookies?.token || null;
|
|
@@ -288,16 +292,18 @@ exports.expressCreateServer = (hookName, context) => {
|
|
|
288
292
|
|
|
289
293
|
const accessResult = await securityManager.checkAccess(padId, sessionCookie, token, user);
|
|
290
294
|
if (accessResult.accessStatus !== 'grant') {
|
|
295
|
+
logger.warn(`[ep_media_upload] UPLOAD_DENIED: ip="${clientIp}" pad="${padId}" reason="access_denied"`);
|
|
291
296
|
return res.status(403).json({ error: 'Access denied to this pad' });
|
|
292
297
|
}
|
|
298
|
+
authorId = accessResult.authorID || 'unknown';
|
|
293
299
|
} catch (authErr) {
|
|
294
300
|
logger.error('[ep_media_upload] Access check error:', authErr);
|
|
295
301
|
return res.status(500).json({ error: 'Access verification failed' });
|
|
296
302
|
}
|
|
297
303
|
|
|
298
304
|
/* ------------------ Rate limiting --------------------- */
|
|
299
|
-
|
|
300
|
-
|
|
305
|
+
if (!_rateLimitCheck(clientIp)) {
|
|
306
|
+
logger.warn(`[ep_media_upload] UPLOAD_RATE_LIMITED: ip="${clientIp}" pad="${padId}"`);
|
|
301
307
|
return res.status(429).json({ error: 'Too many presign requests' });
|
|
302
308
|
}
|
|
303
309
|
|
|
@@ -373,8 +379,8 @@ exports.expressCreateServer = (hookName, context) => {
|
|
|
373
379
|
|
|
374
380
|
// Log upload request for audit trail
|
|
375
381
|
// Note: Never log tokens or session cookies - only non-sensitive identifiers
|
|
376
|
-
const
|
|
377
|
-
logger.info(`[ep_media_upload] UPLOAD: user="${
|
|
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}"`);
|
|
378
384
|
|
|
379
385
|
// Return downloadUrl for hyperlink insertion (authenticated download endpoint)
|
|
380
386
|
// Also return signedUrl for the actual S3 upload and contentDisposition for PUT headers
|
|
@@ -414,6 +420,10 @@ exports.expressCreateServer = (hookName, context) => {
|
|
|
414
420
|
return res.status(500).json({ error: 'Security module unavailable' });
|
|
415
421
|
}
|
|
416
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';
|
|
417
427
|
try {
|
|
418
428
|
const sessionCookie = req.cookies?.sessionID || null;
|
|
419
429
|
const token = req.cookies?.token || null;
|
|
@@ -421,16 +431,18 @@ exports.expressCreateServer = (hookName, context) => {
|
|
|
421
431
|
|
|
422
432
|
const accessResult = await securityManager.checkAccess(padId, sessionCookie, token, user);
|
|
423
433
|
if (accessResult.accessStatus !== 'grant') {
|
|
434
|
+
logger.warn(`[ep_media_upload] DOWNLOAD_DENIED: ip="${clientIp}" pad="${padId}" file="${fileId}" reason="access_denied"`);
|
|
424
435
|
return res.status(403).json({ error: 'Access denied to this pad' });
|
|
425
436
|
}
|
|
437
|
+
authorId = accessResult.authorID || 'unknown';
|
|
426
438
|
} catch (authErr) {
|
|
427
439
|
logger.error('[ep_media_upload] Download access check error:', authErr);
|
|
428
440
|
return res.status(500).json({ error: 'Access verification failed' });
|
|
429
441
|
}
|
|
430
442
|
|
|
431
443
|
/* ------------------ Rate limiting --------------------- */
|
|
432
|
-
|
|
433
|
-
|
|
444
|
+
if (!_rateLimitCheck(clientIp)) {
|
|
445
|
+
logger.warn(`[ep_media_upload] DOWNLOAD_RATE_LIMITED: ip="${clientIp}" pad="${padId}" file="${fileId}"`);
|
|
434
446
|
return res.status(429).json({ error: 'Too many download requests' });
|
|
435
447
|
}
|
|
436
448
|
|
|
@@ -455,11 +467,30 @@ exports.expressCreateServer = (hookName, context) => {
|
|
|
455
467
|
const prefix = keyPrefix || '';
|
|
456
468
|
const key = `${prefix}${padId}/${fileId}`;
|
|
457
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
|
+
|
|
458
487
|
// Generate presigned GET URL with short expiry
|
|
488
|
+
// Use ResponseContentDisposition to override the stored header
|
|
459
489
|
const s3Client = new S3Client({ region });
|
|
460
490
|
const getCommand = new GetObjectCommand({
|
|
461
491
|
Bucket: bucket,
|
|
462
492
|
Key: key,
|
|
493
|
+
ResponseContentDisposition: disposition,
|
|
463
494
|
});
|
|
464
495
|
|
|
465
496
|
// Use downloadExpires from config, default to 300 seconds (5 minutes)
|
|
@@ -467,8 +498,9 @@ exports.expressCreateServer = (hookName, context) => {
|
|
|
467
498
|
const presignedGetUrl = await getSignedUrl(s3Client, getCommand, { expiresIn });
|
|
468
499
|
|
|
469
500
|
// Log download request for audit trail
|
|
470
|
-
const
|
|
471
|
-
|
|
501
|
+
const username = req.session?.user?.username || 'anonymous';
|
|
502
|
+
const dispositionType = shouldOpenInline ? 'inline' : 'attachment';
|
|
503
|
+
logger.info(`[ep_media_upload] DOWNLOAD: author="${authorId}" user="${username}" ip="${clientIp}" pad="${padId}" file="${fileId}" disposition="${dispositionType}"`);
|
|
472
504
|
|
|
473
505
|
// Redirect to the presigned URL
|
|
474
506
|
return res.redirect(302, presignedGetUrl);
|
package/package.json
CHANGED