ep_media_upload 0.1.0 → 0.1.1
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 +2 -2
- package/index.js +23 -6
- package/package.json +4 -3
- package/static/css/ep_media_upload.css +11 -26
- package/static/images/upload-file-svgrepo-com.svg +2 -0
- package/static/js/clientHooks.js +22 -17
- package/templates/uploadModal.ejs +1 -7
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
A lightweight Etherpad plugin that adds file upload capability via an S3 presigned URL workflow. Upon successful upload, a hyperlink is inserted into the document using the same format as `ep_hyperlinked_text
|
|
5
|
+
A lightweight Etherpad plugin that adds file upload capability via an S3 presigned URL workflow. Upon successful upload, a hyperlink is inserted into the document using the same format as `ep_hyperlinked_text`. NOTE: this currently REQUIRES ep_hyperlinked_text to work.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -217,7 +217,7 @@ This ensures:
|
|
|
217
217
|
|
|
218
218
|
- **Etherpad version**: Requires >= 1.8.6 (for ESM Settings module compatibility)
|
|
219
219
|
- **Node.js version**: >= 18.0.0
|
|
220
|
-
- **ep_hyperlinked_text**:
|
|
220
|
+
- **ep_hyperlinked_text**: **Required** – this plugin uses the `hyperlink` attribute which ep_hyperlinked_text renders as clickable links
|
|
221
221
|
- **Read-only pads**: Upload button automatically hidden
|
|
222
222
|
|
|
223
223
|
---
|
package/index.js
CHANGED
|
@@ -72,17 +72,26 @@ const _rateLimitCheck = (ip) => {
|
|
|
72
72
|
/**
|
|
73
73
|
* Validate padId to prevent path traversal and injection attacks.
|
|
74
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.
|
|
75
82
|
*/
|
|
76
83
|
const isValidPadId = (padId) => {
|
|
77
84
|
if (!padId || typeof padId !== 'string') return false;
|
|
85
|
+
if (padId.length === 0 || padId.length > 500) return false; // Reasonable length limits
|
|
78
86
|
// Reject path traversal sequences
|
|
79
87
|
if (padId.includes('..')) return false;
|
|
80
88
|
// Reject null bytes
|
|
81
89
|
if (padId.includes('\0')) return false;
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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;
|
|
86
95
|
};
|
|
87
96
|
|
|
88
97
|
/**
|
|
@@ -330,13 +339,14 @@ exports.expressConfigure = (hookName, context) => {
|
|
|
330
339
|
// This ensures files download with their original name instead of the UUID
|
|
331
340
|
const originalFilename = path.basename(name);
|
|
332
341
|
const safeFilename = originalFilename.replace(/[^\w\-_.]/g, '_'); // Sanitize for header
|
|
342
|
+
const contentDisposition = `attachment; filename="${safeFilename}"`;
|
|
333
343
|
|
|
334
344
|
const putCommand = new PutObjectCommand({
|
|
335
345
|
Bucket: bucket,
|
|
336
346
|
Key: key,
|
|
337
347
|
ContentType: type,
|
|
338
348
|
// Force download instead of opening in browser
|
|
339
|
-
ContentDisposition:
|
|
349
|
+
ContentDisposition: contentDisposition,
|
|
340
350
|
});
|
|
341
351
|
|
|
342
352
|
const signedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: expires || 600 });
|
|
@@ -352,7 +362,14 @@ exports.expressConfigure = (hookName, context) => {
|
|
|
352
362
|
publicUrl = new url.URL(key, s3Base).toString();
|
|
353
363
|
}
|
|
354
364
|
|
|
355
|
-
|
|
365
|
+
// Log upload request for audit trail
|
|
366
|
+
// Note: Never log tokens or session cookies - only non-sensitive identifiers
|
|
367
|
+
const userId = req.session?.user?.username || req.session?.authorId || 'anonymous';
|
|
368
|
+
logger.info(`[ep_media_upload] UPLOAD: user="${userId}" pad="${padId}" file="${originalFilename}" s3key="${key}"`);
|
|
369
|
+
|
|
370
|
+
// Return contentDisposition so client can include it in the PUT request
|
|
371
|
+
// (required because it's part of the presigned URL signature)
|
|
372
|
+
return res.json({ signedUrl, publicUrl, contentDisposition });
|
|
356
373
|
} catch (err) {
|
|
357
374
|
logger.error('[ep_media_upload] S3 presign error', err);
|
|
358
375
|
return res.status(500).json({ error: 'Failed to generate presigned URL' });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ep_media_upload",
|
|
3
|
-
"description": "beta - Upload files to S3 and insert hyperlinks into the pad.
|
|
4
|
-
"version": "0.1.
|
|
3
|
+
"description": "beta - Upload files to S3 and insert hyperlinks into the pad. Requires ep_hyperlinked_text.",
|
|
4
|
+
"version": "0.1.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "DCastelone",
|
|
7
7
|
"url": "https://github.com/dcastelone"
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"@aws-sdk/s3-request-presigner": "^3.555.0"
|
|
13
13
|
},
|
|
14
14
|
"peerDependencies": {
|
|
15
|
-
"ep_etherpad-lite": ">=1.8.6"
|
|
15
|
+
"ep_etherpad-lite": ">=1.8.6",
|
|
16
|
+
"ep_hyperlinked_text": "*"
|
|
16
17
|
},
|
|
17
18
|
"engines": {
|
|
18
19
|
"node": ">=18.0.0"
|
|
@@ -50,28 +50,6 @@
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/* Icons */
|
|
54
|
-
.ep-media-upload-icon {
|
|
55
|
-
width: 48px;
|
|
56
|
-
height: 48px;
|
|
57
|
-
border-radius: 50%;
|
|
58
|
-
display: flex;
|
|
59
|
-
align-items: center;
|
|
60
|
-
justify-content: center;
|
|
61
|
-
font-size: 24px;
|
|
62
|
-
font-weight: bold;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
.ep-media-upload-icon-success {
|
|
66
|
-
background-color: #e6f4ea;
|
|
67
|
-
color: #1e8e3e;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
.ep-media-upload-icon-error {
|
|
71
|
-
background-color: #fce8e6;
|
|
72
|
-
color: #d93025;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
53
|
/* Message text */
|
|
76
54
|
.ep-media-upload-message {
|
|
77
55
|
margin: 0;
|
|
@@ -107,9 +85,16 @@
|
|
|
107
85
|
outline-offset: 2px;
|
|
108
86
|
}
|
|
109
87
|
|
|
110
|
-
/* Toolbar button icon -
|
|
111
|
-
.buttonicon-attachment
|
|
112
|
-
|
|
113
|
-
|
|
88
|
+
/* Toolbar button icon - upload file SVG */
|
|
89
|
+
.buttonicon-attachment {
|
|
90
|
+
background-image: url('../images/upload-file-svgrepo-com.svg');
|
|
91
|
+
background-size: 16px 16px;
|
|
92
|
+
background-repeat: no-repeat;
|
|
93
|
+
background-position: center;
|
|
94
|
+
width: 16px;
|
|
95
|
+
height: 16px;
|
|
114
96
|
}
|
|
115
97
|
|
|
98
|
+
.buttonicon-attachment::before {
|
|
99
|
+
content: "";
|
|
100
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24" width="512" height="512"><path d="M21,3H12.236L8.236,1H3C1.346,1,0,2.346,0,4V23H9v-2H2V9H22v12h-7v2h9V6c0-1.654-1.346-3-3-3ZM2,7v-3c0-.552,.449-1,1-1H7.764l4,2h9.236c.551,0,1,.448,1,1v1H2ZM13,15.003v7.997h-2V14.992l-2.291,2.301-1.414-1.414,3.298-3.298c.375-.376,.875-.583,1.406-.583h0c.531,0,1.031,.207,1.406,.584l3.298,3.297-1.414,1.414-2.291-2.29Z"/></svg>
|
package/static/js/clientHooks.js
CHANGED
|
@@ -18,19 +18,15 @@ let _aceContext = null;
|
|
|
18
18
|
const showModal = (state = 'progress') => {
|
|
19
19
|
const modal = $('#mediaUploadModal');
|
|
20
20
|
const progressEl = $('#mediaUploadProgress');
|
|
21
|
-
const successEl = $('#mediaUploadSuccess');
|
|
22
21
|
const errorEl = $('#mediaUploadError');
|
|
23
22
|
|
|
24
23
|
// Hide all states
|
|
25
24
|
progressEl.hide();
|
|
26
|
-
successEl.hide();
|
|
27
25
|
errorEl.hide();
|
|
28
26
|
|
|
29
27
|
// Show requested state
|
|
30
28
|
if (state === 'progress') {
|
|
31
29
|
progressEl.show();
|
|
32
|
-
} else if (state === 'success') {
|
|
33
|
-
successEl.show();
|
|
34
30
|
} else if (state === 'error') {
|
|
35
31
|
errorEl.show();
|
|
36
32
|
}
|
|
@@ -43,18 +39,11 @@ const hideModal = () => {
|
|
|
43
39
|
};
|
|
44
40
|
|
|
45
41
|
const showError = (message) => {
|
|
46
|
-
|
|
42
|
+
const errorText = message || 'Upload failed.';
|
|
43
|
+
$('.ep-media-upload-error-text').text(errorText);
|
|
47
44
|
showModal('error');
|
|
48
45
|
};
|
|
49
46
|
|
|
50
|
-
const showSuccess = () => {
|
|
51
|
-
showModal('success');
|
|
52
|
-
// Auto-hide after 1.5 seconds
|
|
53
|
-
setTimeout(() => {
|
|
54
|
-
hideModal();
|
|
55
|
-
}, 1500);
|
|
56
|
-
};
|
|
57
|
-
|
|
58
47
|
/**
|
|
59
48
|
* Validate file against configured restrictions
|
|
60
49
|
*/
|
|
@@ -102,9 +91,15 @@ const uploadToS3 = async (file) => {
|
|
|
102
91
|
}
|
|
103
92
|
|
|
104
93
|
// Step 2: Upload directly to S3
|
|
94
|
+
// Must include Content-Disposition header as it's part of the presigned URL signature
|
|
95
|
+
const headers = { 'Content-Type': file.type };
|
|
96
|
+
if (presignResponse.contentDisposition) {
|
|
97
|
+
headers['Content-Disposition'] = presignResponse.contentDisposition;
|
|
98
|
+
}
|
|
99
|
+
|
|
105
100
|
const uploadResponse = await fetch(presignResponse.signedUrl, {
|
|
106
101
|
method: 'PUT',
|
|
107
|
-
headers
|
|
102
|
+
headers,
|
|
108
103
|
body: file,
|
|
109
104
|
});
|
|
110
105
|
|
|
@@ -172,12 +167,22 @@ const handleFileUpload = async (file, aceContext) => {
|
|
|
172
167
|
ace.ace_doInsertMediaLink(publicUrl, file.name);
|
|
173
168
|
}, 'insertMediaLink', true);
|
|
174
169
|
|
|
175
|
-
//
|
|
176
|
-
|
|
170
|
+
// Hide modal on success (no success message needed)
|
|
171
|
+
hideModal();
|
|
177
172
|
|
|
178
173
|
} catch (err) {
|
|
179
174
|
console.error('[ep_media_upload] Upload failed:', err);
|
|
180
|
-
|
|
175
|
+
// Extract error message from various error formats
|
|
176
|
+
let errorMsg = 'Upload failed.';
|
|
177
|
+
if (err.responseJSON && err.responseJSON.error) {
|
|
178
|
+
// jQuery AJAX error with JSON response
|
|
179
|
+
errorMsg = err.responseJSON.error;
|
|
180
|
+
} else if (err.message) {
|
|
181
|
+
// Standard Error object
|
|
182
|
+
errorMsg = err.message;
|
|
183
|
+
} else if (typeof err === 'string') {
|
|
184
|
+
errorMsg = err;
|
|
185
|
+
}
|
|
181
186
|
showError(errorMsg);
|
|
182
187
|
}
|
|
183
188
|
};
|
|
@@ -4,15 +4,9 @@
|
|
|
4
4
|
<div class="ep-media-upload-spinner"></div>
|
|
5
5
|
<p class="ep-media-upload-message" data-l10n-id="ep_media_upload.status.uploading">Uploading...</p>
|
|
6
6
|
</div>
|
|
7
|
-
<div id="mediaUploadSuccess" class="ep-media-upload-state" style="display: none;">
|
|
8
|
-
<div class="ep-media-upload-icon ep-media-upload-icon-success">✓</div>
|
|
9
|
-
<p class="ep-media-upload-message" data-l10n-id="ep_media_upload.status.success">Upload complete!</p>
|
|
10
|
-
</div>
|
|
11
7
|
<div id="mediaUploadError" class="ep-media-upload-state" style="display: none;">
|
|
12
|
-
<
|
|
13
|
-
<p class="ep-media-upload-message ep-media-upload-error-text"></p>
|
|
8
|
+
<p class="ep-media-upload-message ep-media-upload-error-text">Upload failed.</p>
|
|
14
9
|
<button id="mediaUploadErrorClose" class="ep-media-upload-btn" data-l10n-id="ep_media_upload.button.close">Close</button>
|
|
15
10
|
</div>
|
|
16
11
|
</div>
|
|
17
12
|
</div>
|
|
18
|
-
|