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 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`, making the two plugins fully compatible.
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**: Fully compatible inserted links render/export identically
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
- // Allow alphanumeric, hyphens, underscores, and $ (for group pads)
83
- // This is permissive but still prevents dangerous characters
84
- const safePattern = /^[a-zA-Z0-9_\-$]+$/;
85
- return safePattern.test(padId);
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: `attachment; filename="${safeFilename}"`,
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
- return res.json({ signedUrl, publicUrl });
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. Compatible with ep_hyperlinked_text.",
4
- "version": "0.1.0",
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 - paperclip */
111
- .buttonicon-attachment::before {
112
- content: "📎";
113
- font-size: 16px;
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>
@@ -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
- $('.ep-media-upload-error-text').text(message);
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: { 'Content-Type': file.type },
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
- // Show success
176
- showSuccess();
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
- const errorMsg = html10n.get('ep_media_upload.error.uploadFailed') || 'Upload failed. Please try again.';
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
- <div class="ep-media-upload-icon ep-media-upload-icon-error">✕</div>
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
-