ep_media_upload 0.1.1 → 0.2.0

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
@@ -1,266 +1,95 @@
1
- # ep_media_upload – BETA: Etherpad Media Upload Plugin
1
+ # ep_media_upload
2
2
 
3
- ## Overview
3
+ Etherpad plugin for secure file uploads via S3 presigned URLs.
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`. NOTE: this currently REQUIRES ep_hyperlinked_text to work.
5
+ **Requires:** `ep_hyperlinked_text`
6
6
 
7
- ---
7
+ ## How It Works
8
8
 
9
- ## Features
9
+ 1. User clicks paperclip button → selects file
10
+ 2. Client uploads directly to S3 (server never handles file data)
11
+ 3. Hyperlink inserted into document pointing to secure download endpoint
12
+ 4. On click, Etherpad verifies access and redirects to short-lived S3 URL
10
13
 
11
- ### Toolbar Integration
12
- - **Paperclip icon** button in the left editbar menu
13
- - Button is **hidden in read-only mode** (uses `acl-write` class)
14
- - Triggers native file picker dialog on click
14
+ **S3 bucket can be completely private** – downloads go through authenticated Etherpad endpoint.
15
15
 
16
- ### Upload Workflow
17
- - **Client-side presigned URL pattern** (identical to ep_images_extended)
18
- 1. Client requests presigned PUT URL from Etherpad server
19
- 2. Server generates presigned URL using AWS SDK v3 (credentials from environment variables, not settings.json)
20
- 3. Client uploads file directly to S3 (server never touches file)
21
- 4. On success, client inserts hyperlink into document
22
- - **No base64 or local storage options** – S3 only
23
- - **Scalable & secure**: Server only generates presigned URLs, no file handling
16
+ ## Configuration
24
17
 
25
- ### File Restrictions
26
- - **Allowed file types**: Configurable via `settings.json` (array of extensions without dots)
27
- - **Maximum file size**: Configurable via `settings.json` (in bytes)
18
+ ### settings.json
28
19
 
29
- ### Document Integration
30
- - On upload success, inserts a **hyperlink** into the document
31
- - **Link text**: Original filename (e.g., "quarterly-report.pdf")
32
- - **Link URL**: S3 public/CDN URL for direct download
33
- - **Hyperlink format**: 100% compatible with `ep_hyperlinked_text` plugin
34
- - Uses `hyperlink` attribute with URL value
35
- - Renders as clickable `<a>` tag with `target="_blank"`
36
-
37
- ### Upload Feedback UI
38
- - **Progress modal** during upload:
39
- - Shows "Uploading..." message
40
- - Basic visual indicator (e.g., spinner or progress text)
41
- - **Success state**: Brief confirmation, then modal dismisses
42
- - **Error state**: Shows error message with dismiss button
43
- - Modal positioned center-screen (similar to ep_images_extended loader)
44
-
45
- ---
46
-
47
- ## Configuration (settings.json)
48
-
49
- ```jsonc
20
+ ```json
50
21
  "ep_media_upload": {
51
22
  "storage": {
52
- "type": "s3_presigned", // Only supported type
53
- "region": "us-east-1", // AWS region
54
- "bucket": "my-bucket-name", // S3 bucket name
55
- "keyPrefix": "uploads/", // Optional S3 key prefix (for CloudFront path-based routing)
56
- "publicURL": "https://cdn.example.com/uploads/", // Optional CDN URL (should include prefix if using keyPrefix)
57
- "expires": 900 // Presigned URL expiry in seconds (default 600)
23
+ "type": "s3_presigned",
24
+ "region": "us-east-1",
25
+ "bucket": "my-bucket-name",
26
+ "keyPrefix": "uploads/",
27
+ "expires": 900,
28
+ "downloadExpires": 300
58
29
  },
59
- "fileTypes": ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "mp3", "mp4", "wav", "mov", "zip", "txt"],
60
- "maxFileSize": 52428800 // 50 MB in bytes
30
+ "fileTypes": ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "zip"],
31
+ "maxFileSize": 52428800
61
32
  }
62
33
  ```
63
34
 
64
- ### Storage Options Explained
35
+ ### Storage Options
65
36
 
66
- | Option | Description |
67
- |--------|-------------|
68
- | `type` | Must be `"s3_presigned"` (only supported storage type) |
69
- | `region` | AWS region (e.g., `"us-east-1"`) |
70
- | `bucket` | S3 bucket name |
71
- | `keyPrefix` | Optional prefix for S3 keys (e.g., `"uploads/"` → keys become `uploads/padId/uuid.ext`) |
72
- | `publicURL` | Optional CDN/custom URL base. If using `keyPrefix`, include it in this URL. |
73
- | `expires` | Presigned URL expiry in seconds (default: 600) |
37
+ | Option | Required | Default | Description |
38
+ |--------|----------|---------|-------------|
39
+ | `type` | Yes | — | Must be `"s3_presigned"` |
40
+ | `region` | Yes | — | AWS region (e.g., `"us-east-1"`) |
41
+ | `bucket` | Yes | — | S3 bucket name |
42
+ | `keyPrefix` | No | `""` | Prefix for S3 keys (e.g., `"uploads/"`) |
43
+ | `expires` | No | `600` | Upload URL expiry in seconds (10 min) |
44
+ | `downloadExpires` | No | `300` | Download URL expiry in seconds (5 min) |
74
45
 
75
- **Example with CloudFront path-based routing:**
76
- ```jsonc
77
- "storage": {
78
- "type": "s3_presigned",
79
- "region": "us-east-1",
80
- "bucket": "my-bucket",
81
- "keyPrefix": "uploads/", // S3 key: uploads/padId/uuid.pdf
82
- "publicURL": "https://d123.cloudfront.net/uploads/" // Public URL includes prefix
83
- }
84
- ```
85
-
86
- ### Environment Variables (AWS Credentials)
87
- - `AWS_ACCESS_KEY_ID`
88
- - `AWS_SECRET_ACCESS_KEY`
89
- - `AWS_SESSION_TOKEN` (optional, for temporary credentials)
46
+ ### Other Options
90
47
 
91
- ---
48
+ | Option | Required | Default | Description |
49
+ |--------|----------|---------|-------------|
50
+ | `fileTypes` | No | all | Array of allowed extensions (without dots) |
51
+ | `maxFileSize` | No | unlimited | Max file size in bytes |
92
52
 
93
- ## File Structure
53
+ ### Environment Variables
94
54
 
95
55
  ```
96
- ep_media_upload/
97
- ├── ep.json # Plugin manifest (hooks registration)
98
- ├── index.js # Server-side hooks (presign endpoint, clientVars)
99
- ├── package.json # NPM package definition
100
- ├── locales/
101
- │ └── en.json # English translations
102
- ├── static/
103
- │ ├── css/
104
- │ │ └── ep_media_upload.css # Modal styles
105
- │ └── js/
106
- │ └── clientHooks.js # Client-side upload logic
107
- └── templates/
108
- ├── uploadButton.ejs # Toolbar button HTML
109
- └── uploadModal.ejs # Progress/error modal HTML
56
+ AWS_ACCESS_KEY_ID=...
57
+ AWS_SECRET_ACCESS_KEY=...
110
58
  ```
111
59
 
112
- ---
113
-
114
- ## Hook Registration (ep.json)
115
-
116
- ### Client Hooks
117
- - `postToolbarInit` – Register toolbar button command
118
- - `postAceInit` – (Optional) Any initialization after editor ready
119
-
120
- ### Server Hooks
121
- - `eejsBlock_editbarMenuLeft` – Inject toolbar button HTML
122
- - `eejsBlock_body` – Inject modal HTML
123
- - `expressConfigure` – Register `/p/:padId/pluginfw/ep_media_upload/s3_presign` endpoint
124
- - `clientVars` – Pass config to client (fileTypes, maxFileSize, storageType)
125
- - `loadSettings` – Sync settings to runtime
60
+ ## S3 Setup
126
61
 
127
- ---
62
+ ### Block Public Access
128
63
 
129
- ## Server Endpoint: Presign
64
+ All four "Block Public Access" settings can be enabled since downloads go through Etherpad.
130
65
 
131
- ### Route
132
- ```
133
- GET /p/:padId/pluginfw/ep_media_upload/s3_presign?name=<filename>&type=<mimetype>
134
- ```
135
-
136
- ### Authentication
137
- - Validates session (cookie-based or express session)
138
- - Rate limiting: Max 30 requests per IP per minute (configurable)
66
+ ### CORS (for uploads)
139
67
 
140
- ### Response
141
68
  ```json
142
- {
143
- "signedUrl": "https://bucket.s3.region.amazonaws.com/padId/uuid.ext?...",
144
- "publicUrl": "https://cdn.example.com/padId/uuid.ext"
145
- }
146
- ```
147
-
148
- ### Security
149
- - File extension validated against allowed `fileTypes`
150
- - Unique filename generated: `<padId>/<uuid>.<ext>`
151
- - MIME type passed to S3 for proper Content-Type header
152
-
153
- ---
154
-
155
- ## Client Upload Flow
156
-
157
- 1. User clicks paperclip button
158
- 2. File picker opens (native `<input type="file">`)
159
- 3. User selects file
160
- 4. **Validation** (client-side):
161
- - Check file extension against `clientVars.ep_media_upload.fileTypes`
162
- - Check file size against `clientVars.ep_media_upload.maxFileSize`
163
- - Show error modal if validation fails
164
- 5. **Show upload modal** with "Uploading..." state
165
- 6. **Request presigned URL** from server
166
- 7. **PUT file to S3** using presigned URL
167
- 8. **On success**:
168
- - Show brief success message
169
- - Dismiss modal
170
- - Insert hyperlink at cursor position using `ace_doInsertMediaLink()`
171
- 9. **On failure**:
172
- - Show error message in modal
173
- - User dismisses manually
174
-
175
- ---
176
-
177
- ## Hyperlink Insertion
178
-
179
- Uses the same mechanism as `ep_hyperlinked_text`:
180
-
181
- ```javascript
182
- // Insert text with hyperlink attribute
183
- const filename = file.name; // e.g., "report.pdf"
184
- const url = publicUrl; // e.g., "https://cdn.example.com/padId/abc123.pdf"
185
-
186
- // Insert filename text at cursor
187
- editorInfo.ace_replaceRange(cursorPos, cursorPos, filename);
188
-
189
- // Apply hyperlink attribute to the inserted text
190
- docMan.setAttributesOnRange(
191
- [cursorPos[0], cursorPos[1]],
192
- [cursorPos[0], cursorPos[1] + filename.length],
193
- [['hyperlink', url]]
194
- );
69
+ [{
70
+ "AllowedOrigins": ["https://your-etherpad-domain.com"],
71
+ "AllowedMethods": ["PUT"],
72
+ "AllowedHeaders": ["Content-Type", "Content-Disposition"],
73
+ "MaxAgeSeconds": 3000
74
+ }]
195
75
  ```
196
76
 
197
- This ensures:
198
- - Full compatibility with ep_hyperlinked_text rendering
199
- - Clickable links that open in new tab
200
- - Proper HTML export with `<a>` tags
201
-
202
- ---
203
-
204
- ## Error Handling
205
-
206
- | Error | User Message |
207
- |-------|--------------|
208
- | Invalid file type | "File type not allowed. Allowed types: pdf, doc, ..." |
209
- | File too large | "File is too large. Maximum size: 50 MB." |
210
- | Presign request failed | "Upload failed. Please try again." |
211
- | S3 upload failed | "Upload failed. Please try again." |
212
- | Network error | "Network error. Please check your connection." |
213
-
214
- ---
215
-
216
- ## Compatibility Notes
217
-
218
- - **Etherpad version**: Requires >= 1.8.6 (for ESM Settings module compatibility)
219
- - **Node.js version**: >= 18.0.0
220
- - **ep_hyperlinked_text**: **Required** – this plugin uses the `hyperlink` attribute which ep_hyperlinked_text renders as clickable links
221
- - **Read-only pads**: Upload button automatically hidden
222
-
223
- ---
77
+ ### IAM Permissions
224
78
 
225
- ## Security Considerations
79
+ - `s3:PutObject`
80
+ - `s3:GetObject`
226
81
 
227
- 1. **No server-side file handling**: Files never touch the Etherpad server
228
- 2. **Authentication required**: Presign endpoint validates session
229
- 3. **Rate limiting**: Prevents presign endpoint abuse
230
- 4. **File type allowlist**: Only configured extensions accepted
231
- 5. **Unique filenames**: UUIDs prevent enumeration/overwrites
232
- 6. **CORS on S3**: Bucket must allow PUT from pad origins
82
+ ## Security
233
83
 
234
- ---
235
-
236
- ## S3 Bucket CORS Configuration
237
-
238
- Required CORS policy for the S3 bucket:
239
-
240
- ```json
241
- [
242
- {
243
- "AllowedOrigins": ["https://your-etherpad-domain.com"],
244
- "AllowedMethods": ["PUT"],
245
- "AllowedHeaders": ["Content-Type"],
246
- "MaxAgeSeconds": 3000
247
- }
248
- ]
249
- ```
250
-
251
- ---
84
+ - **Authentication**: All endpoints require valid Etherpad session
85
+ - **Fail-closed**: Requests denied if security module unavailable
86
+ - **Rate limiting**: 30 requests/IP/minute
87
+ - **Input validation**: Path traversal protection on all parameters
88
+ - **Short-lived URLs**: Download links expire quickly (configurable)
89
+ - **Audit logging**: All uploads/downloads logged
252
90
 
253
91
  ## Dependencies
254
92
 
255
- ```json
256
- {
257
- "dependencies": {
258
- "@aws-sdk/client-s3": "^3.555.0",
259
- "@aws-sdk/s3-request-presigner": "^3.555.0"
260
- },
261
- "peerDependencies": {
262
- "ep_etherpad-lite": ">=1.8.6"
263
- }
264
- }
265
- ```
266
-
93
+ - `@aws-sdk/client-s3`
94
+ - `@aws-sdk/s3-request-presigner`
95
+ - `ep_etherpad-lite` >= 1.8.6
package/ep.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "hooks": {
11
11
  "eejsBlock_editbarMenuLeft": "ep_media_upload/index",
12
12
  "eejsBlock_body": "ep_media_upload/index",
13
- "expressConfigure": "ep_media_upload/index",
13
+ "expressCreateServer": "ep_media_upload/index",
14
14
  "clientVars": "ep_media_upload/index",
15
15
  "loadSettings": "ep_media_upload/index"
16
16
  }
package/index.js CHANGED
@@ -17,9 +17,9 @@ try {
17
17
  }
18
18
 
19
19
  // AWS SDK v3 for presigned URLs
20
- let S3Client, PutObjectCommand, getSignedUrl;
20
+ let S3Client, PutObjectCommand, GetObjectCommand, getSignedUrl;
21
21
  try {
22
- ({ S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'));
22
+ ({ S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3'));
23
23
  ({ getSignedUrl } = require('@aws-sdk/s3-request-presigner'));
24
24
  } catch (e) {
25
25
  console.warn('[ep_media_upload] AWS SDK not installed; s3_presigned storage will not work.');
@@ -172,6 +172,24 @@ const isValidMimeForExtension = (extension, mimeType) => {
172
172
  return allowedMimes.some(allowed => allowed === normalizedMime);
173
173
  };
174
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
+
175
193
  // ============================================================================
176
194
  // Hooks
177
195
  // ============================================================================
@@ -240,10 +258,10 @@ exports.eejsBlock_body = (hookName, args, cb) => {
240
258
  };
241
259
 
242
260
  /**
243
- * expressConfigure hook
244
- * Register the S3 presign endpoint
261
+ * expressCreateServer hook
262
+ * Register the S3 presign and download endpoints
245
263
  */
246
- exports.expressConfigure = (hookName, context) => {
264
+ exports.expressCreateServer = (hookName, context) => {
247
265
  logger.info('[ep_media_upload] Registering presign endpoint');
248
266
 
249
267
  // Route: GET /p/:padId/pluginfw/ep_media_upload/s3_presign
@@ -257,27 +275,24 @@ exports.expressConfigure = (hookName, context) => {
257
275
 
258
276
  /* ------------------ Pad Access Verification ------------------ */
259
277
  // Use Etherpad's SecurityManager to verify user has access to this pad
260
- if (securityManager) {
261
- try {
262
- const sessionCookie = req.cookies?.sessionID || null;
263
- const token = req.cookies?.token || null;
264
- const user = req.session?.user || null;
265
-
266
- const accessResult = await securityManager.checkAccess(padId, sessionCookie, token, user);
267
- if (accessResult.accessStatus !== 'grant') {
268
- return res.status(403).json({ error: 'Access denied to this pad' });
269
- }
270
- } catch (authErr) {
271
- logger.error('[ep_media_upload] Access check error:', authErr);
272
- return res.status(500).json({ error: 'Access verification failed' });
273
- }
274
- } else {
275
- // Fallback: basic cookie check if SecurityManager unavailable
276
- const hasExpressSession = req.session && (req.session.user || req.session.authorId);
277
- const hasPadCookie = req.cookies && (req.cookies.sessionID || req.cookies.token);
278
- if (!hasExpressSession && !hasPadCookie) {
279
- return res.status(401).json({ error: 'Authentication required' });
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
+ try {
285
+ const sessionCookie = req.cookies?.sessionID || null;
286
+ const token = req.cookies?.token || null;
287
+ const user = req.session?.user || null;
288
+
289
+ const accessResult = await securityManager.checkAccess(padId, sessionCookie, token, user);
290
+ if (accessResult.accessStatus !== 'grant') {
291
+ return res.status(403).json({ error: 'Access denied to this pad' });
280
292
  }
293
+ } catch (authErr) {
294
+ logger.error('[ep_media_upload] Access check error:', authErr);
295
+ return res.status(500).json({ error: 'Access verification failed' });
281
296
  }
282
297
 
283
298
  /* ------------------ Rate limiting --------------------- */
@@ -296,7 +311,7 @@ exports.expressConfigure = (hookName, context) => {
296
311
  return res.status(500).json({ error: 'AWS SDK not available on server' });
297
312
  }
298
313
 
299
- const { bucket, region, publicURL, expires, keyPrefix } = storageCfg;
314
+ const { bucket, region, expires, keyPrefix } = storageCfg;
300
315
  if (!bucket || !region) {
301
316
  return res.status(500).json({ error: 'Invalid S3 configuration: missing bucket or region' });
302
317
  }
@@ -351,28 +366,122 @@ exports.expressConfigure = (hookName, context) => {
351
366
 
352
367
  const signedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: expires || 600 });
353
368
 
354
- // Build public URL:
355
- // - If custom publicURL is set (e.g., CDN), it already includes the prefix path
356
- // - If no publicURL, use direct S3 URL with full key
357
- let publicUrl;
358
- if (publicURL) {
359
- publicUrl = new url.URL(objectPath, publicURL).toString();
360
- } else {
361
- const s3Base = `https://${bucket}.s3.${region}.amazonaws.com/`;
362
- publicUrl = new url.URL(key, s3Base).toString();
363
- }
369
+ // Build secure download URL (relative path that goes through our auth-protected endpoint)
370
+ // Using query parameter for fileId to ensure Express 4/5 compatibility (path params don't handle dots well in Express 5)
371
+ const fileId = path.basename(key); // e.g., "abc123-def456.pdf"
372
+ const downloadUrl = `/p/${encodeURIComponent(padId)}/pluginfw/ep_media_upload/download?file=${encodeURIComponent(fileId)}`;
364
373
 
365
374
  // Log upload request for audit trail
366
375
  // Note: Never log tokens or session cookies - only non-sensitive identifiers
367
376
  const userId = req.session?.user?.username || req.session?.authorId || 'anonymous';
368
377
  logger.info(`[ep_media_upload] UPLOAD: user="${userId}" pad="${padId}" file="${originalFilename}" s3key="${key}"`);
369
378
 
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 });
379
+ // Return downloadUrl for hyperlink insertion (authenticated download endpoint)
380
+ // Also return signedUrl for the actual S3 upload and contentDisposition for PUT headers
381
+ return res.json({ signedUrl, downloadUrl, contentDisposition });
373
382
  } catch (err) {
374
383
  logger.error('[ep_media_upload] S3 presign error', err);
375
384
  return res.status(500).json({ error: 'Failed to generate presigned URL' });
376
385
  }
377
386
  });
387
+
388
+ // ============================================================================
389
+ // Download Endpoint - Secure file access via presigned GET URL redirect
390
+ // ============================================================================
391
+ // Route: GET /p/:padId/pluginfw/ep_media_upload/download?file=<fileId>
392
+ // Using query parameter for fileId to ensure Express 4/5 compatibility
393
+ logger.info('[ep_media_upload] Registering download endpoint');
394
+
395
+ context.app.get('/p/:padId/pluginfw/ep_media_upload/download', async (req, res) => {
396
+ const { padId } = req.params;
397
+ const fileId = req.query.file;
398
+
399
+ /* ------------------ Validate padId ------------------ */
400
+ if (!isValidPadId(padId)) {
401
+ return res.status(400).json({ error: 'Invalid pad ID' });
402
+ }
403
+
404
+ /* ------------------ Validate fileId ------------------ */
405
+ if (!isValidFileId(fileId)) {
406
+ return res.status(400).json({ error: 'Invalid file ID' });
407
+ }
408
+
409
+ /* ------------------ Pad Access Verification ------------------ */
410
+ // Use Etherpad's SecurityManager to verify user has access to this pad
411
+ // SECURITY: Fail closed - if SecurityManager is unavailable, deny all requests
412
+ if (!securityManager) {
413
+ logger.error('[ep_media_upload] SECURITY: SecurityManager unavailable - denying download request. This should not happen in a properly configured Etherpad instance.');
414
+ return res.status(500).json({ error: 'Security module unavailable' });
415
+ }
416
+
417
+ try {
418
+ const sessionCookie = req.cookies?.sessionID || null;
419
+ const token = req.cookies?.token || null;
420
+ const user = req.session?.user || null;
421
+
422
+ const accessResult = await securityManager.checkAccess(padId, sessionCookie, token, user);
423
+ if (accessResult.accessStatus !== 'grant') {
424
+ return res.status(403).json({ error: 'Access denied to this pad' });
425
+ }
426
+ } catch (authErr) {
427
+ logger.error('[ep_media_upload] Download access check error:', authErr);
428
+ return res.status(500).json({ error: 'Access verification failed' });
429
+ }
430
+
431
+ /* ------------------ Rate limiting --------------------- */
432
+ const ip = req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress || 'unknown';
433
+ if (!_rateLimitCheck(ip)) {
434
+ return res.status(429).json({ error: 'Too many download requests' });
435
+ }
436
+
437
+ try {
438
+ const storageCfg = settings.ep_media_upload && settings.ep_media_upload.storage;
439
+ if (!storageCfg || storageCfg.type !== 's3_presigned') {
440
+ return res.status(400).json({ error: 's3_presigned storage not configured' });
441
+ }
442
+
443
+ if (!S3Client || !GetObjectCommand || !getSignedUrl) {
444
+ return res.status(500).json({ error: 'AWS SDK not available on server' });
445
+ }
446
+
447
+ const { bucket, region, keyPrefix, downloadExpires } = storageCfg;
448
+ if (!bucket || !region) {
449
+ return res.status(500).json({ error: 'Invalid S3 configuration: missing bucket or region' });
450
+ }
451
+
452
+ // Construct S3 key from padId and fileId
453
+ // Key format: keyPrefix + padId + "/" + fileId
454
+ // e.g., "uploads/myPad/abc123-def456.pdf"
455
+ const prefix = keyPrefix || '';
456
+ const key = `${prefix}${padId}/${fileId}`;
457
+
458
+ // Generate presigned GET URL with short expiry
459
+ const s3Client = new S3Client({ region });
460
+ const getCommand = new GetObjectCommand({
461
+ Bucket: bucket,
462
+ Key: key,
463
+ });
464
+
465
+ // Use downloadExpires from config, default to 300 seconds (5 minutes)
466
+ const expiresIn = downloadExpires || 300;
467
+ const presignedGetUrl = await getSignedUrl(s3Client, getCommand, { expiresIn });
468
+
469
+ // Log download request for audit trail
470
+ const userId = req.session?.user?.username || req.session?.authorId || 'anonymous';
471
+ logger.info(`[ep_media_upload] DOWNLOAD: user="${userId}" pad="${padId}" file="${fileId}"`);
472
+
473
+ // Redirect to the presigned URL
474
+ return res.redirect(302, presignedGetUrl);
475
+
476
+ } catch (err) {
477
+ logger.error('[ep_media_upload] Download presign error:', err);
478
+
479
+ // Check if this is a "NoSuchKey" error (file doesn't exist in S3)
480
+ if (err.name === 'NoSuchKey' || err.Code === 'NoSuchKey') {
481
+ return res.status(404).json({ error: 'File not found' });
482
+ }
483
+
484
+ return res.status(500).json({ error: 'Failed to generate download URL' });
485
+ }
486
+ });
378
487
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ep_media_upload",
3
3
  "description": "beta - Upload files to S3 and insert hyperlinks into the pad. Requires ep_hyperlinked_text.",
4
- "version": "0.1.1",
4
+ "version": "0.2.0",
5
5
  "author": {
6
6
  "name": "DCastelone",
7
7
  "url": "https://github.com/dcastelone"
@@ -78,6 +78,7 @@ const validateFile = (file) => {
78
78
 
79
79
  /**
80
80
  * Upload file to S3 using presigned URL
81
+ * Returns the secure download URL (relative path to our authenticated endpoint)
81
82
  */
82
83
  const uploadToS3 = async (file) => {
83
84
  // Step 1: Get presigned URL from server
@@ -86,7 +87,7 @@ const uploadToS3 = async (file) => {
86
87
  `${encodeURIComponent(clientVars.padId)}/pluginfw/ep_media_upload/s3_presign?${queryParams}`
87
88
  );
88
89
 
89
- if (!presignResponse || !presignResponse.signedUrl || !presignResponse.publicUrl) {
90
+ if (!presignResponse || !presignResponse.signedUrl || !presignResponse.downloadUrl) {
90
91
  throw new Error('Invalid presign response from server');
91
92
  }
92
93
 
@@ -107,7 +108,8 @@ const uploadToS3 = async (file) => {
107
108
  throw new Error(`S3 upload failed with status ${uploadResponse.status}`);
108
109
  }
109
110
 
110
- return presignResponse.publicUrl;
111
+ // Return the secure download URL (authenticated endpoint, not direct S3)
112
+ return presignResponse.downloadUrl;
111
113
  };
112
114
 
113
115
  /**
@@ -159,12 +161,12 @@ const handleFileUpload = async (file, aceContext) => {
159
161
  showModal('progress');
160
162
 
161
163
  try {
162
- // Upload to S3
163
- const publicUrl = await uploadToS3(file);
164
+ // Upload to S3 and get secure download URL
165
+ const downloadUrl = await uploadToS3(file);
164
166
 
165
- // Insert hyperlink into document
167
+ // Insert hyperlink into document (uses authenticated download endpoint)
166
168
  aceContext.callWithAce((ace) => {
167
- ace.ace_doInsertMediaLink(publicUrl, file.name);
169
+ ace.ace_doInsertMediaLink(downloadUrl, file.name);
168
170
  }, 'insertMediaLink', true);
169
171
 
170
172
  // Hide modal on success (no success message needed)