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 +60 -231
- package/ep.json +1 -1
- package/index.js +148 -39
- package/package.json +1 -1
- package/static/js/clientHooks.js +8 -6
package/README.md
CHANGED
|
@@ -1,266 +1,95 @@
|
|
|
1
|
-
# ep_media_upload
|
|
1
|
+
# ep_media_upload
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Etherpad plugin for secure file uploads via S3 presigned URLs.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**Requires:** `ep_hyperlinked_text`
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## How It Works
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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",
|
|
53
|
-
"region": "us-east-1",
|
|
54
|
-
"bucket": "my-bucket-name",
|
|
55
|
-
"keyPrefix": "uploads/",
|
|
56
|
-
"
|
|
57
|
-
"
|
|
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", "
|
|
60
|
-
"maxFileSize": 52428800
|
|
30
|
+
"fileTypes": ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "zip"],
|
|
31
|
+
"maxFileSize": 52428800
|
|
61
32
|
}
|
|
62
33
|
```
|
|
63
34
|
|
|
64
|
-
### Storage Options
|
|
35
|
+
### Storage Options
|
|
65
36
|
|
|
66
|
-
| Option | Description |
|
|
67
|
-
|
|
68
|
-
| `type` | Must be `"s3_presigned"`
|
|
69
|
-
| `region` | AWS region (e.g., `"us-east-1"`) |
|
|
70
|
-
| `bucket` | S3 bucket name |
|
|
71
|
-
| `keyPrefix` |
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
53
|
+
### Environment Variables
|
|
94
54
|
|
|
95
55
|
```
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
64
|
+
All four "Block Public Access" settings can be enabled since downloads go through Etherpad.
|
|
130
65
|
|
|
131
|
-
###
|
|
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
|
-
"
|
|
144
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
79
|
+
- `s3:PutObject`
|
|
80
|
+
- `s3:GetObject`
|
|
226
81
|
|
|
227
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
"
|
|
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
|
-
*
|
|
244
|
-
* Register the S3 presign
|
|
261
|
+
* expressCreateServer hook
|
|
262
|
+
* Register the S3 presign and download endpoints
|
|
245
263
|
*/
|
|
246
|
-
exports.
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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,
|
|
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
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
371
|
-
//
|
|
372
|
-
return res.json({ signedUrl,
|
|
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
package/static/js/clientHooks.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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(
|
|
169
|
+
ace.ace_doInsertMediaLink(downloadUrl, file.name);
|
|
168
170
|
}, 'insertMediaLink', true);
|
|
169
171
|
|
|
170
172
|
// Hide modal on success (no success message needed)
|