ep_media_upload 0.1.0 → 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 +168 -42
- package/package.json +4 -3
- package/static/css/ep_media_upload.css +11 -26
- package/static/images/upload-file-svgrepo-com.svg +2 -0
- package/static/js/clientHooks.js +30 -23
- package/templates/uploadModal.ejs +1 -7
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**: Fully compatible – inserted links render/export identically
|
|
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.');
|
|
@@ -72,17 +72,26 @@ const _rateLimitCheck = (ip) => {
|
|
|
72
72
|
/**
|
|
73
73
|
* Validate padId to prevent path traversal and injection attacks.
|
|
74
74
|
* Returns true if valid, false if invalid.
|
|
75
|
+
*
|
|
76
|
+
* Etherpad pad IDs can contain various characters including:
|
|
77
|
+
* - Alphanumeric, hyphens, underscores
|
|
78
|
+
* - Dots and colons (common in pad names)
|
|
79
|
+
* - $ (for group pads, e.g., g.xxxxxxxx$padName)
|
|
80
|
+
*
|
|
81
|
+
* We use a blocklist approach to reject only dangerous patterns.
|
|
75
82
|
*/
|
|
76
83
|
const isValidPadId = (padId) => {
|
|
77
84
|
if (!padId || typeof padId !== 'string') return false;
|
|
85
|
+
if (padId.length === 0 || padId.length > 500) return false; // Reasonable length limits
|
|
78
86
|
// Reject path traversal sequences
|
|
79
87
|
if (padId.includes('..')) return false;
|
|
80
88
|
// Reject null bytes
|
|
81
89
|
if (padId.includes('\0')) return false;
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
// Reject slashes (forward and back) to prevent path manipulation
|
|
91
|
+
if (padId.includes('/') || padId.includes('\\')) return false;
|
|
92
|
+
// Reject control characters (ASCII 0-31)
|
|
93
|
+
if (/[\x00-\x1f]/.test(padId)) return false;
|
|
94
|
+
return true;
|
|
86
95
|
};
|
|
87
96
|
|
|
88
97
|
/**
|
|
@@ -163,6 +172,24 @@ const isValidMimeForExtension = (extension, mimeType) => {
|
|
|
163
172
|
return allowedMimes.some(allowed => allowed === normalizedMime);
|
|
164
173
|
};
|
|
165
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
|
+
|
|
166
193
|
// ============================================================================
|
|
167
194
|
// Hooks
|
|
168
195
|
// ============================================================================
|
|
@@ -231,10 +258,10 @@ exports.eejsBlock_body = (hookName, args, cb) => {
|
|
|
231
258
|
};
|
|
232
259
|
|
|
233
260
|
/**
|
|
234
|
-
*
|
|
235
|
-
* Register the S3 presign
|
|
261
|
+
* expressCreateServer hook
|
|
262
|
+
* Register the S3 presign and download endpoints
|
|
236
263
|
*/
|
|
237
|
-
exports.
|
|
264
|
+
exports.expressCreateServer = (hookName, context) => {
|
|
238
265
|
logger.info('[ep_media_upload] Registering presign endpoint');
|
|
239
266
|
|
|
240
267
|
// Route: GET /p/:padId/pluginfw/ep_media_upload/s3_presign
|
|
@@ -248,27 +275,24 @@ exports.expressConfigure = (hookName, context) => {
|
|
|
248
275
|
|
|
249
276
|
/* ------------------ Pad Access Verification ------------------ */
|
|
250
277
|
// Use Etherpad's SecurityManager to verify user has access to this pad
|
|
251
|
-
if
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
} else {
|
|
266
|
-
// Fallback: basic cookie check if SecurityManager unavailable
|
|
267
|
-
const hasExpressSession = req.session && (req.session.user || req.session.authorId);
|
|
268
|
-
const hasPadCookie = req.cookies && (req.cookies.sessionID || req.cookies.token);
|
|
269
|
-
if (!hasExpressSession && !hasPadCookie) {
|
|
270
|
-
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' });
|
|
271
292
|
}
|
|
293
|
+
} catch (authErr) {
|
|
294
|
+
logger.error('[ep_media_upload] Access check error:', authErr);
|
|
295
|
+
return res.status(500).json({ error: 'Access verification failed' });
|
|
272
296
|
}
|
|
273
297
|
|
|
274
298
|
/* ------------------ Rate limiting --------------------- */
|
|
@@ -287,7 +311,7 @@ exports.expressConfigure = (hookName, context) => {
|
|
|
287
311
|
return res.status(500).json({ error: 'AWS SDK not available on server' });
|
|
288
312
|
}
|
|
289
313
|
|
|
290
|
-
const { bucket, region,
|
|
314
|
+
const { bucket, region, expires, keyPrefix } = storageCfg;
|
|
291
315
|
if (!bucket || !region) {
|
|
292
316
|
return res.status(500).json({ error: 'Invalid S3 configuration: missing bucket or region' });
|
|
293
317
|
}
|
|
@@ -330,32 +354,134 @@ exports.expressConfigure = (hookName, context) => {
|
|
|
330
354
|
// This ensures files download with their original name instead of the UUID
|
|
331
355
|
const originalFilename = path.basename(name);
|
|
332
356
|
const safeFilename = originalFilename.replace(/[^\w\-_.]/g, '_'); // Sanitize for header
|
|
357
|
+
const contentDisposition = `attachment; filename="${safeFilename}"`;
|
|
333
358
|
|
|
334
359
|
const putCommand = new PutObjectCommand({
|
|
335
360
|
Bucket: bucket,
|
|
336
361
|
Key: key,
|
|
337
362
|
ContentType: type,
|
|
338
363
|
// Force download instead of opening in browser
|
|
339
|
-
ContentDisposition:
|
|
364
|
+
ContentDisposition: contentDisposition,
|
|
340
365
|
});
|
|
341
366
|
|
|
342
367
|
const signedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: expires || 600 });
|
|
343
368
|
|
|
344
|
-
// Build
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (publicURL) {
|
|
349
|
-
publicUrl = new url.URL(objectPath, publicURL).toString();
|
|
350
|
-
} else {
|
|
351
|
-
const s3Base = `https://${bucket}.s3.${region}.amazonaws.com/`;
|
|
352
|
-
publicUrl = new url.URL(key, s3Base).toString();
|
|
353
|
-
}
|
|
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)}`;
|
|
354
373
|
|
|
355
|
-
|
|
374
|
+
// Log upload request for audit trail
|
|
375
|
+
// Note: Never log tokens or session cookies - only non-sensitive identifiers
|
|
376
|
+
const userId = req.session?.user?.username || req.session?.authorId || 'anonymous';
|
|
377
|
+
logger.info(`[ep_media_upload] UPLOAD: user="${userId}" pad="${padId}" file="${originalFilename}" s3key="${key}"`);
|
|
378
|
+
|
|
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 });
|
|
356
382
|
} catch (err) {
|
|
357
383
|
logger.error('[ep_media_upload] S3 presign error', err);
|
|
358
384
|
return res.status(500).json({ error: 'Failed to generate presigned URL' });
|
|
359
385
|
}
|
|
360
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
|
+
});
|
|
361
487
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ep_media_upload",
|
|
3
|
-
"description": "beta - Upload files to S3 and insert hyperlinks into the pad.
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "beta - Upload files to S3 and insert hyperlinks into the pad. Requires ep_hyperlinked_text.",
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "DCastelone",
|
|
7
7
|
"url": "https://github.com/dcastelone"
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"@aws-sdk/s3-request-presigner": "^3.555.0"
|
|
13
13
|
},
|
|
14
14
|
"peerDependencies": {
|
|
15
|
-
"ep_etherpad-lite": ">=1.8.6"
|
|
15
|
+
"ep_etherpad-lite": ">=1.8.6",
|
|
16
|
+
"ep_hyperlinked_text": "*"
|
|
16
17
|
},
|
|
17
18
|
"engines": {
|
|
18
19
|
"node": ">=18.0.0"
|
|
@@ -50,28 +50,6 @@
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/* Icons */
|
|
54
|
-
.ep-media-upload-icon {
|
|
55
|
-
width: 48px;
|
|
56
|
-
height: 48px;
|
|
57
|
-
border-radius: 50%;
|
|
58
|
-
display: flex;
|
|
59
|
-
align-items: center;
|
|
60
|
-
justify-content: center;
|
|
61
|
-
font-size: 24px;
|
|
62
|
-
font-weight: bold;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
.ep-media-upload-icon-success {
|
|
66
|
-
background-color: #e6f4ea;
|
|
67
|
-
color: #1e8e3e;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
.ep-media-upload-icon-error {
|
|
71
|
-
background-color: #fce8e6;
|
|
72
|
-
color: #d93025;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
53
|
/* Message text */
|
|
76
54
|
.ep-media-upload-message {
|
|
77
55
|
margin: 0;
|
|
@@ -107,9 +85,16 @@
|
|
|
107
85
|
outline-offset: 2px;
|
|
108
86
|
}
|
|
109
87
|
|
|
110
|
-
/* Toolbar button icon -
|
|
111
|
-
.buttonicon-attachment
|
|
112
|
-
|
|
113
|
-
|
|
88
|
+
/* Toolbar button icon - upload file SVG */
|
|
89
|
+
.buttonicon-attachment {
|
|
90
|
+
background-image: url('../images/upload-file-svgrepo-com.svg');
|
|
91
|
+
background-size: 16px 16px;
|
|
92
|
+
background-repeat: no-repeat;
|
|
93
|
+
background-position: center;
|
|
94
|
+
width: 16px;
|
|
95
|
+
height: 16px;
|
|
114
96
|
}
|
|
115
97
|
|
|
98
|
+
.buttonicon-attachment::before {
|
|
99
|
+
content: "";
|
|
100
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24" width="512" height="512"><path d="M21,3H12.236L8.236,1H3C1.346,1,0,2.346,0,4V23H9v-2H2V9H22v12h-7v2h9V6c0-1.654-1.346-3-3-3ZM2,7v-3c0-.552,.449-1,1-1H7.764l4,2h9.236c.551,0,1,.448,1,1v1H2ZM13,15.003v7.997h-2V14.992l-2.291,2.301-1.414-1.414,3.298-3.298c.375-.376,.875-.583,1.406-.583h0c.531,0,1.031,.207,1.406,.584l3.298,3.297-1.414,1.414-2.291-2.29Z"/></svg>
|
package/static/js/clientHooks.js
CHANGED
|
@@ -18,19 +18,15 @@ let _aceContext = null;
|
|
|
18
18
|
const showModal = (state = 'progress') => {
|
|
19
19
|
const modal = $('#mediaUploadModal');
|
|
20
20
|
const progressEl = $('#mediaUploadProgress');
|
|
21
|
-
const successEl = $('#mediaUploadSuccess');
|
|
22
21
|
const errorEl = $('#mediaUploadError');
|
|
23
22
|
|
|
24
23
|
// Hide all states
|
|
25
24
|
progressEl.hide();
|
|
26
|
-
successEl.hide();
|
|
27
25
|
errorEl.hide();
|
|
28
26
|
|
|
29
27
|
// Show requested state
|
|
30
28
|
if (state === 'progress') {
|
|
31
29
|
progressEl.show();
|
|
32
|
-
} else if (state === 'success') {
|
|
33
|
-
successEl.show();
|
|
34
30
|
} else if (state === 'error') {
|
|
35
31
|
errorEl.show();
|
|
36
32
|
}
|
|
@@ -43,18 +39,11 @@ const hideModal = () => {
|
|
|
43
39
|
};
|
|
44
40
|
|
|
45
41
|
const showError = (message) => {
|
|
46
|
-
|
|
42
|
+
const errorText = message || 'Upload failed.';
|
|
43
|
+
$('.ep-media-upload-error-text').text(errorText);
|
|
47
44
|
showModal('error');
|
|
48
45
|
};
|
|
49
46
|
|
|
50
|
-
const showSuccess = () => {
|
|
51
|
-
showModal('success');
|
|
52
|
-
// Auto-hide after 1.5 seconds
|
|
53
|
-
setTimeout(() => {
|
|
54
|
-
hideModal();
|
|
55
|
-
}, 1500);
|
|
56
|
-
};
|
|
57
|
-
|
|
58
47
|
/**
|
|
59
48
|
* Validate file against configured restrictions
|
|
60
49
|
*/
|
|
@@ -89,6 +78,7 @@ const validateFile = (file) => {
|
|
|
89
78
|
|
|
90
79
|
/**
|
|
91
80
|
* Upload file to S3 using presigned URL
|
|
81
|
+
* Returns the secure download URL (relative path to our authenticated endpoint)
|
|
92
82
|
*/
|
|
93
83
|
const uploadToS3 = async (file) => {
|
|
94
84
|
// Step 1: Get presigned URL from server
|
|
@@ -97,14 +87,20 @@ const uploadToS3 = async (file) => {
|
|
|
97
87
|
`${encodeURIComponent(clientVars.padId)}/pluginfw/ep_media_upload/s3_presign?${queryParams}`
|
|
98
88
|
);
|
|
99
89
|
|
|
100
|
-
if (!presignResponse || !presignResponse.signedUrl || !presignResponse.
|
|
90
|
+
if (!presignResponse || !presignResponse.signedUrl || !presignResponse.downloadUrl) {
|
|
101
91
|
throw new Error('Invalid presign response from server');
|
|
102
92
|
}
|
|
103
93
|
|
|
104
94
|
// Step 2: Upload directly to S3
|
|
95
|
+
// Must include Content-Disposition header as it's part of the presigned URL signature
|
|
96
|
+
const headers = { 'Content-Type': file.type };
|
|
97
|
+
if (presignResponse.contentDisposition) {
|
|
98
|
+
headers['Content-Disposition'] = presignResponse.contentDisposition;
|
|
99
|
+
}
|
|
100
|
+
|
|
105
101
|
const uploadResponse = await fetch(presignResponse.signedUrl, {
|
|
106
102
|
method: 'PUT',
|
|
107
|
-
headers
|
|
103
|
+
headers,
|
|
108
104
|
body: file,
|
|
109
105
|
});
|
|
110
106
|
|
|
@@ -112,7 +108,8 @@ const uploadToS3 = async (file) => {
|
|
|
112
108
|
throw new Error(`S3 upload failed with status ${uploadResponse.status}`);
|
|
113
109
|
}
|
|
114
110
|
|
|
115
|
-
|
|
111
|
+
// Return the secure download URL (authenticated endpoint, not direct S3)
|
|
112
|
+
return presignResponse.downloadUrl;
|
|
116
113
|
};
|
|
117
114
|
|
|
118
115
|
/**
|
|
@@ -164,20 +161,30 @@ const handleFileUpload = async (file, aceContext) => {
|
|
|
164
161
|
showModal('progress');
|
|
165
162
|
|
|
166
163
|
try {
|
|
167
|
-
// Upload to S3
|
|
168
|
-
const
|
|
164
|
+
// Upload to S3 and get secure download URL
|
|
165
|
+
const downloadUrl = await uploadToS3(file);
|
|
169
166
|
|
|
170
|
-
// Insert hyperlink into document
|
|
167
|
+
// Insert hyperlink into document (uses authenticated download endpoint)
|
|
171
168
|
aceContext.callWithAce((ace) => {
|
|
172
|
-
ace.ace_doInsertMediaLink(
|
|
169
|
+
ace.ace_doInsertMediaLink(downloadUrl, file.name);
|
|
173
170
|
}, 'insertMediaLink', true);
|
|
174
171
|
|
|
175
|
-
//
|
|
176
|
-
|
|
172
|
+
// Hide modal on success (no success message needed)
|
|
173
|
+
hideModal();
|
|
177
174
|
|
|
178
175
|
} catch (err) {
|
|
179
176
|
console.error('[ep_media_upload] Upload failed:', err);
|
|
180
|
-
|
|
177
|
+
// Extract error message from various error formats
|
|
178
|
+
let errorMsg = 'Upload failed.';
|
|
179
|
+
if (err.responseJSON && err.responseJSON.error) {
|
|
180
|
+
// jQuery AJAX error with JSON response
|
|
181
|
+
errorMsg = err.responseJSON.error;
|
|
182
|
+
} else if (err.message) {
|
|
183
|
+
// Standard Error object
|
|
184
|
+
errorMsg = err.message;
|
|
185
|
+
} else if (typeof err === 'string') {
|
|
186
|
+
errorMsg = err;
|
|
187
|
+
}
|
|
181
188
|
showError(errorMsg);
|
|
182
189
|
}
|
|
183
190
|
};
|
|
@@ -4,15 +4,9 @@
|
|
|
4
4
|
<div class="ep-media-upload-spinner"></div>
|
|
5
5
|
<p class="ep-media-upload-message" data-l10n-id="ep_media_upload.status.uploading">Uploading...</p>
|
|
6
6
|
</div>
|
|
7
|
-
<div id="mediaUploadSuccess" class="ep-media-upload-state" style="display: none;">
|
|
8
|
-
<div class="ep-media-upload-icon ep-media-upload-icon-success">✓</div>
|
|
9
|
-
<p class="ep-media-upload-message" data-l10n-id="ep_media_upload.status.success">Upload complete!</p>
|
|
10
|
-
</div>
|
|
11
7
|
<div id="mediaUploadError" class="ep-media-upload-state" style="display: none;">
|
|
12
|
-
<
|
|
13
|
-
<p class="ep-media-upload-message ep-media-upload-error-text"></p>
|
|
8
|
+
<p class="ep-media-upload-message ep-media-upload-error-text">Upload failed.</p>
|
|
14
9
|
<button id="mediaUploadErrorClose" class="ep-media-upload-btn" data-l10n-id="ep_media_upload.button.close">Close</button>
|
|
15
10
|
</div>
|
|
16
11
|
</div>
|
|
17
12
|
</div>
|
|
18
|
-
|