ep_media_upload 0.1.1 → 0.2.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 +60 -231
- package/ep.json +1 -1
- package/index.js +164 -43
- 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,32 +275,35 @@ 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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
+
// 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';
|
|
288
|
+
try {
|
|
289
|
+
const sessionCookie = req.cookies?.sessionID || null;
|
|
290
|
+
const token = req.cookies?.token || null;
|
|
291
|
+
const user = req.session?.user || null;
|
|
292
|
+
|
|
293
|
+
const accessResult = await securityManager.checkAccess(padId, sessionCookie, token, user);
|
|
294
|
+
if (accessResult.accessStatus !== 'grant') {
|
|
295
|
+
logger.warn(`[ep_media_upload] UPLOAD_DENIED: ip="${clientIp}" pad="${padId}" reason="access_denied"`);
|
|
296
|
+
return res.status(403).json({ error: 'Access denied to this pad' });
|
|
280
297
|
}
|
|
298
|
+
authorId = accessResult.authorID || 'unknown';
|
|
299
|
+
} catch (authErr) {
|
|
300
|
+
logger.error('[ep_media_upload] Access check error:', authErr);
|
|
301
|
+
return res.status(500).json({ error: 'Access verification failed' });
|
|
281
302
|
}
|
|
282
303
|
|
|
283
304
|
/* ------------------ Rate limiting --------------------- */
|
|
284
|
-
|
|
285
|
-
|
|
305
|
+
if (!_rateLimitCheck(clientIp)) {
|
|
306
|
+
logger.warn(`[ep_media_upload] UPLOAD_RATE_LIMITED: ip="${clientIp}" pad="${padId}"`);
|
|
286
307
|
return res.status(429).json({ error: 'Too many presign requests' });
|
|
287
308
|
}
|
|
288
309
|
|
|
@@ -296,7 +317,7 @@ exports.expressConfigure = (hookName, context) => {
|
|
|
296
317
|
return res.status(500).json({ error: 'AWS SDK not available on server' });
|
|
297
318
|
}
|
|
298
319
|
|
|
299
|
-
const { bucket, region,
|
|
320
|
+
const { bucket, region, expires, keyPrefix } = storageCfg;
|
|
300
321
|
if (!bucket || !region) {
|
|
301
322
|
return res.status(500).json({ error: 'Invalid S3 configuration: missing bucket or region' });
|
|
302
323
|
}
|
|
@@ -351,28 +372,128 @@ exports.expressConfigure = (hookName, context) => {
|
|
|
351
372
|
|
|
352
373
|
const signedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: expires || 600 });
|
|
353
374
|
|
|
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
|
-
}
|
|
375
|
+
// Build secure download URL (relative path that goes through our auth-protected endpoint)
|
|
376
|
+
// Using query parameter for fileId to ensure Express 4/5 compatibility (path params don't handle dots well in Express 5)
|
|
377
|
+
const fileId = path.basename(key); // e.g., "abc123-def456.pdf"
|
|
378
|
+
const downloadUrl = `/p/${encodeURIComponent(padId)}/pluginfw/ep_media_upload/download?file=${encodeURIComponent(fileId)}`;
|
|
364
379
|
|
|
365
380
|
// Log upload request for audit trail
|
|
366
381
|
// Note: Never log tokens or session cookies - only non-sensitive identifiers
|
|
367
|
-
const
|
|
368
|
-
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}"`);
|
|
369
384
|
|
|
370
|
-
// Return
|
|
371
|
-
//
|
|
372
|
-
return res.json({ signedUrl,
|
|
385
|
+
// Return downloadUrl for hyperlink insertion (authenticated download endpoint)
|
|
386
|
+
// Also return signedUrl for the actual S3 upload and contentDisposition for PUT headers
|
|
387
|
+
return res.json({ signedUrl, downloadUrl, contentDisposition });
|
|
373
388
|
} catch (err) {
|
|
374
389
|
logger.error('[ep_media_upload] S3 presign error', err);
|
|
375
390
|
return res.status(500).json({ error: 'Failed to generate presigned URL' });
|
|
376
391
|
}
|
|
377
392
|
});
|
|
393
|
+
|
|
394
|
+
// ============================================================================
|
|
395
|
+
// Download Endpoint - Secure file access via presigned GET URL redirect
|
|
396
|
+
// ============================================================================
|
|
397
|
+
// Route: GET /p/:padId/pluginfw/ep_media_upload/download?file=<fileId>
|
|
398
|
+
// Using query parameter for fileId to ensure Express 4/5 compatibility
|
|
399
|
+
logger.info('[ep_media_upload] Registering download endpoint');
|
|
400
|
+
|
|
401
|
+
context.app.get('/p/:padId/pluginfw/ep_media_upload/download', async (req, res) => {
|
|
402
|
+
const { padId } = req.params;
|
|
403
|
+
const fileId = req.query.file;
|
|
404
|
+
|
|
405
|
+
/* ------------------ Validate padId ------------------ */
|
|
406
|
+
if (!isValidPadId(padId)) {
|
|
407
|
+
return res.status(400).json({ error: 'Invalid pad ID' });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/* ------------------ Validate fileId ------------------ */
|
|
411
|
+
if (!isValidFileId(fileId)) {
|
|
412
|
+
return res.status(400).json({ error: 'Invalid file ID' });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/* ------------------ Pad Access Verification ------------------ */
|
|
416
|
+
// Use Etherpad's SecurityManager to verify user has access to this pad
|
|
417
|
+
// SECURITY: Fail closed - if SecurityManager is unavailable, deny all requests
|
|
418
|
+
if (!securityManager) {
|
|
419
|
+
logger.error('[ep_media_upload] SECURITY: SecurityManager unavailable - denying download request. This should not happen in a properly configured Etherpad instance.');
|
|
420
|
+
return res.status(500).json({ error: 'Security module unavailable' });
|
|
421
|
+
}
|
|
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';
|
|
427
|
+
try {
|
|
428
|
+
const sessionCookie = req.cookies?.sessionID || null;
|
|
429
|
+
const token = req.cookies?.token || null;
|
|
430
|
+
const user = req.session?.user || null;
|
|
431
|
+
|
|
432
|
+
const accessResult = await securityManager.checkAccess(padId, sessionCookie, token, user);
|
|
433
|
+
if (accessResult.accessStatus !== 'grant') {
|
|
434
|
+
logger.warn(`[ep_media_upload] DOWNLOAD_DENIED: ip="${clientIp}" pad="${padId}" file="${fileId}" reason="access_denied"`);
|
|
435
|
+
return res.status(403).json({ error: 'Access denied to this pad' });
|
|
436
|
+
}
|
|
437
|
+
authorId = accessResult.authorID || 'unknown';
|
|
438
|
+
} catch (authErr) {
|
|
439
|
+
logger.error('[ep_media_upload] Download access check error:', authErr);
|
|
440
|
+
return res.status(500).json({ error: 'Access verification failed' });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/* ------------------ Rate limiting --------------------- */
|
|
444
|
+
if (!_rateLimitCheck(clientIp)) {
|
|
445
|
+
logger.warn(`[ep_media_upload] DOWNLOAD_RATE_LIMITED: ip="${clientIp}" pad="${padId}" file="${fileId}"`);
|
|
446
|
+
return res.status(429).json({ error: 'Too many download requests' });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
const storageCfg = settings.ep_media_upload && settings.ep_media_upload.storage;
|
|
451
|
+
if (!storageCfg || storageCfg.type !== 's3_presigned') {
|
|
452
|
+
return res.status(400).json({ error: 's3_presigned storage not configured' });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!S3Client || !GetObjectCommand || !getSignedUrl) {
|
|
456
|
+
return res.status(500).json({ error: 'AWS SDK not available on server' });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const { bucket, region, keyPrefix, downloadExpires } = storageCfg;
|
|
460
|
+
if (!bucket || !region) {
|
|
461
|
+
return res.status(500).json({ error: 'Invalid S3 configuration: missing bucket or region' });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Construct S3 key from padId and fileId
|
|
465
|
+
// Key format: keyPrefix + padId + "/" + fileId
|
|
466
|
+
// e.g., "uploads/myPad/abc123-def456.pdf"
|
|
467
|
+
const prefix = keyPrefix || '';
|
|
468
|
+
const key = `${prefix}${padId}/${fileId}`;
|
|
469
|
+
|
|
470
|
+
// Generate presigned GET URL with short expiry
|
|
471
|
+
const s3Client = new S3Client({ region });
|
|
472
|
+
const getCommand = new GetObjectCommand({
|
|
473
|
+
Bucket: bucket,
|
|
474
|
+
Key: key,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Use downloadExpires from config, default to 300 seconds (5 minutes)
|
|
478
|
+
const expiresIn = downloadExpires || 300;
|
|
479
|
+
const presignedGetUrl = await getSignedUrl(s3Client, getCommand, { expiresIn });
|
|
480
|
+
|
|
481
|
+
// Log download request for audit trail
|
|
482
|
+
const username = req.session?.user?.username || 'anonymous';
|
|
483
|
+
logger.info(`[ep_media_upload] DOWNLOAD: author="${authorId}" user="${username}" ip="${clientIp}" pad="${padId}" file="${fileId}"`);
|
|
484
|
+
|
|
485
|
+
// Redirect to the presigned URL
|
|
486
|
+
return res.redirect(302, presignedGetUrl);
|
|
487
|
+
|
|
488
|
+
} catch (err) {
|
|
489
|
+
logger.error('[ep_media_upload] Download presign error:', err);
|
|
490
|
+
|
|
491
|
+
// Check if this is a "NoSuchKey" error (file doesn't exist in S3)
|
|
492
|
+
if (err.name === 'NoSuchKey' || err.Code === 'NoSuchKey') {
|
|
493
|
+
return res.status(404).json({ error: 'File not found' });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return res.status(500).json({ error: 'Failed to generate download URL' });
|
|
497
|
+
}
|
|
498
|
+
});
|
|
378
499
|
};
|
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)
|