ep_images_extended 1.0.2 → 1.0.4
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 +24 -20
- package/ep.json +0 -1
- package/index.js +148 -54
- package/package.json +10 -2
- package/static/js/clientHooks.js +11 -4
- package/static/js/toolbar.js +42 -1
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
`ep_images_extended` builds on `ep_image_insert` and other image upload plugins.
|
|
5
5
|
The main difference is that images are built as custom span structures using the CSS background image attribute. This bypasses the Content Collector which always requires images to be block-level styles (so they couldn't share the line with text). As a result, we can now type around images, which allows the creation of more interactive pad content. The plugin includes some other features like click + drag resize, image float, and cut/copy/delete through a context menu. It was designed for compatibility with my forthcoming tables plugin. It's a pretty heavyweight plugin (some would say overengineered), because I was prioritizing meeting functional requirements for my project. Etherpad wizards might have tips for optimization, it would surely be appreciated.
|
|
6
6
|
|
|
7
|
+

|
|
7
8
|
---
|
|
8
9
|
|
|
9
10
|
## Installation
|
|
@@ -30,10 +31,13 @@ Create (or merge) an **`ep_images_extended`** block at the root of `settings.jso
|
|
|
30
31
|
|
|
31
32
|
1. **Embedded Base-64** (default – zero config)
|
|
32
33
|
```jsonc
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
"ep_images_extended": {
|
|
35
|
+
"storage": {
|
|
36
|
+
"type": "base64"
|
|
37
|
+
},
|
|
38
|
+
"fileTypes": ["jpeg", "jpg", "png", "gif", "bmp", "webp"],
|
|
39
|
+
"maxFileSize": 5000000
|
|
40
|
+
}
|
|
37
41
|
```
|
|
38
42
|
Images are converted to data-URIs and live inside the pad. This has a pretty big performance impact.
|
|
39
43
|
|
|
@@ -58,22 +62,22 @@ Create (or merge) an **`ep_images_extended`** block at the root of `settings.jso
|
|
|
58
62
|
* `AWS_SECRET_ACCESS_KEY`
|
|
59
63
|
* `AWS_SESSION_TOKEN` (if using temporary credentials)
|
|
60
64
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
65
|
+
3. **Local disk storage** (files saved on the Etherpad server)
|
|
66
|
+
```jsonc
|
|
67
|
+
"ep_images_extended": {
|
|
68
|
+
"storage": {
|
|
69
|
+
"type": "local", // enable disk uploads
|
|
70
|
+
"baseFolder": "static/images", // optional – path relative to Etherpad root
|
|
71
|
+
"baseURL": "https://pad.example.com/etherpad-lite/static/images/" // optional – public URL prefix
|
|
72
|
+
},
|
|
73
|
+
"fileTypes": ["jpeg", "jpg", "png", "gif"],
|
|
74
|
+
"maxFileSize": 5000000
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
The browser POSTs the file to `/pluginfw/ep_images_extended/upload`.
|
|
78
|
+
Etherpad writes it to `baseFolder/<padId>/<uuid>.ext` and returns the
|
|
79
|
+
public URL.
|
|
80
|
+
|
|
77
81
|
---
|
|
78
82
|
|
|
79
83
|
## Contributing
|
package/ep.json
CHANGED
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
"expressConfigure": "ep_images_extended/index",
|
|
24
24
|
"eejsBlock_editbarMenuLeft": "ep_images_extended/editbar",
|
|
25
25
|
"loadSettings": "ep_images_extended/settings",
|
|
26
|
-
"padRemove": "ep_images_extended/index",
|
|
27
26
|
"collectContentPre": "ep_images_extended/static/js/contentCollection",
|
|
28
27
|
"eejsBlock_styles": "ep_images_extended/index",
|
|
29
28
|
"eejsBlock_timesliderStyles": "ep_images_extended/index",
|
package/index.js
CHANGED
|
@@ -103,76 +103,170 @@ exports.expressConfigure = (hookName, context) => {
|
|
|
103
103
|
* New endpoint: GET /p/:padId/pluginfw/ep_images_extended/s3_presign
|
|
104
104
|
* ------------------------------------------------------------------
|
|
105
105
|
* Returns: { signedUrl: string, publicUrl: string }
|
|
106
|
-
*
|
|
106
|
+
* Register the route only when storage.type === 's3_presigned'
|
|
107
107
|
*/
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
/* ------------------ Rate limiting --------------------- */
|
|
117
|
-
const ip = req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress || 'unknown';
|
|
118
|
-
if (!_rateLimitCheck(ip)) {
|
|
119
|
-
return res.status(429).json({ error: 'Too many presign requests' });
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
try {
|
|
123
|
-
const storageCfg = settings.ep_images_extended && settings.ep_images_extended.storage;
|
|
124
|
-
if (!storageCfg || storageCfg.type !== 's3_presigned') {
|
|
125
|
-
return res.status(400).json({ error: 's3_presigned storage not enabled' });
|
|
108
|
+
if (settings.ep_images_extended && settings.ep_images_extended.storage && settings.ep_images_extended.storage.type === 's3_presigned') {
|
|
109
|
+
context.app.get('/p/:padId/pluginfw/ep_images_extended/s3_presign', async (req, res) => {
|
|
110
|
+
/* ------------------ Basic auth check ------------------ */
|
|
111
|
+
const hasExpressSession = req.session && (req.session.user || req.session.authorId);
|
|
112
|
+
const hasPadCookie = req.cookies && (req.cookies.sessionID || req.cookies.token);
|
|
113
|
+
if (!hasExpressSession && !hasPadCookie) {
|
|
114
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
126
115
|
}
|
|
127
116
|
|
|
128
|
-
|
|
129
|
-
|
|
117
|
+
/* ------------------ Rate limiting --------------------- */
|
|
118
|
+
const ip = req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress || 'unknown';
|
|
119
|
+
if (!_rateLimitCheck(ip)) {
|
|
120
|
+
return res.status(429).json({ error: 'Too many presign requests' });
|
|
130
121
|
}
|
|
131
122
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
123
|
+
try {
|
|
124
|
+
const storageCfg = settings.ep_images_extended && settings.ep_images_extended.storage;
|
|
125
|
+
if (!storageCfg || storageCfg.type !== 's3_presigned') {
|
|
126
|
+
return res.status(400).json({ error: 's3_presigned storage not enabled' });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!S3Client || !PutObjectCommand || !getSignedUrl) {
|
|
130
|
+
return res.status(500).json({ error: 'AWS SDK not available on server' });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const { bucket, region, publicURL, expires } = storageCfg;
|
|
134
|
+
if (!bucket || !region) {
|
|
135
|
+
return res.status(500).json({ error: 'Invalid S3 configuration' });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { padId } = req.params;
|
|
139
|
+
const { name, type } = req.query;
|
|
140
|
+
if (!name || !type) {
|
|
141
|
+
return res.status(400).json({ error: 'Missing name or type' });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ------------- MIME / extension allow-list ------------ */
|
|
145
|
+
if (settings.ep_images_extended && settings.ep_images_extended.fileTypes && Array.isArray(settings.ep_images_extended.fileTypes)) {
|
|
146
|
+
const allowedExts = settings.ep_images_extended.fileTypes;
|
|
147
|
+
const extName = path.extname(name).replace('.', '').toLowerCase();
|
|
148
|
+
if (!allowedExts.includes(extName)) {
|
|
149
|
+
return res.status(400).json({ error: 'File type not allowed' });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ext = path.extname(name);
|
|
154
|
+
// Ensure ext starts with '.'; if not, prefix it
|
|
155
|
+
const safeExt = ext.startsWith('.') ? ext : `.${ext}`;
|
|
156
|
+
const key = `${padId}/${randomUUID()}${safeExt}`;
|
|
157
|
+
|
|
158
|
+
const s3Client = new S3Client({ region }); // credentials from env / IAM role
|
|
159
|
+
|
|
160
|
+
const putCommand = new PutObjectCommand({
|
|
161
|
+
Bucket: bucket,
|
|
162
|
+
Key: key,
|
|
163
|
+
ContentType: type,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const signedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: expires || 600 });
|
|
167
|
+
|
|
168
|
+
const basePublic = publicURL || `https://${bucket}.s3.${region}.amazonaws.com/`;
|
|
169
|
+
const publicUrl = new url.URL(key, basePublic).toString();
|
|
170
|
+
|
|
171
|
+
return res.json({ signedUrl, publicUrl });
|
|
172
|
+
} catch (err) {
|
|
173
|
+
logger.error('[ep_images_extended] S3 presign error', err);
|
|
174
|
+
return res.status(500).json({ error: 'Failed to generate presigned URL' });
|
|
135
175
|
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
136
178
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
179
|
+
// ADD LOCAL DISK STORAGE UPLOAD ENDPOINT ------------------------------
|
|
180
|
+
// Register the route only if storage.type === 'local'
|
|
181
|
+
if (settings.ep_images_extended && settings.ep_images_extended.storage && settings.ep_images_extended.storage.type === 'local') {
|
|
182
|
+
// Route: POST /p/:padId/pluginfw/ep_images_extended/upload
|
|
183
|
+
// Accepts multipart/form-data with field "file" and saves it to the
|
|
184
|
+
// configured baseFolder. Responds with the public URL of the uploaded file.
|
|
185
|
+
context.app.post('/p/:padId/pluginfw/ep_images_extended/upload', async (req, res) => {
|
|
186
|
+
/* ------------------ Basic auth check ------------------ */
|
|
187
|
+
const hasExpressSession = req.session && (req.session.user || req.session.authorId);
|
|
188
|
+
const hasPadCookie = req.cookies && (req.cookies.sessionID || req.cookies.token);
|
|
189
|
+
if (!hasExpressSession && !hasPadCookie) {
|
|
190
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
141
191
|
}
|
|
142
192
|
|
|
143
|
-
/*
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (!allowedExts.includes(extName)) {
|
|
148
|
-
return res.status(400).json({ error: 'File type not allowed' });
|
|
149
|
-
}
|
|
193
|
+
/* ------------------ Rate limiting --------------------- */
|
|
194
|
+
const ip = req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress || 'unknown';
|
|
195
|
+
if (!_rateLimitCheck(ip)) {
|
|
196
|
+
return res.status(429).json({ error: 'Too many uploads' });
|
|
150
197
|
}
|
|
151
198
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
199
|
+
try {
|
|
200
|
+
// Dynamically require formidable only when needed
|
|
201
|
+
const formidableMod = require('formidable');
|
|
202
|
+
const IncomingForm = formidableMod.IncomingForm || formidableMod; // support both v1 and v2+ exports
|
|
203
|
+
const form = new IncomingForm({ multiples: false, maxFileSize: settings.ep_images_extended.maxFileSize || 1024 * 1024 * 20 /* 20 MB default */ });
|
|
156
204
|
|
|
157
|
-
|
|
205
|
+
form.parse(req, async (err, _fields, files) => {
|
|
206
|
+
if (err) {
|
|
207
|
+
logger.error('[ep_images_extended] formidable parse error', err);
|
|
208
|
+
return res.status(400).json({ error: 'Invalid form data' });
|
|
209
|
+
}
|
|
210
|
+
if (!files.file) {
|
|
211
|
+
return res.status(400).json({ error: 'No file provided' });
|
|
212
|
+
}
|
|
213
|
+
const uploaded = Array.isArray(files.file) ? files.file[0] : files.file;
|
|
214
|
+
const mimeType = uploaded.mimetype || uploaded.type || 'application/octet-stream';
|
|
158
215
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
});
|
|
216
|
+
// Reject non-image MIME types
|
|
217
|
+
if (!mimeType.startsWith('image/')) {
|
|
218
|
+
return res.status(400).json({ error: 'Not an image MIME type' });
|
|
219
|
+
}
|
|
164
220
|
|
|
165
|
-
|
|
221
|
+
// Enforce fileTypes allow-list if configured
|
|
222
|
+
if (settings.ep_images_extended && Array.isArray(settings.ep_images_extended.fileTypes)) {
|
|
223
|
+
const allowedExts = settings.ep_images_extended.fileTypes;
|
|
224
|
+
const extName = path.extname(uploaded.originalFilename || uploaded.name).replace('.', '').toLowerCase();
|
|
225
|
+
if (!allowedExts.includes(extName)) {
|
|
226
|
+
return res.status(400).json({ error: 'File type not allowed' });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
166
229
|
|
|
167
|
-
|
|
168
|
-
|
|
230
|
+
const { padId } = req.params;
|
|
231
|
+
const safePad = path.basename(padId); // prevent path traversal
|
|
232
|
+
const baseFolder = settings.ep_images_extended.storage.baseFolder || path.join(settings.root || process.cwd(), 'src/static/images');
|
|
233
|
+
const destFolder = path.resolve(baseFolder, safePad);
|
|
234
|
+
await fsp.mkdir(destFolder, { recursive: true });
|
|
169
235
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
236
|
+
const newFilename = `${randomUUID()}${path.extname(uploaded.originalFilename || uploaded.name)}`;
|
|
237
|
+
const destPath = path.join(destFolder, newFilename);
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
await fsp.rename(uploaded.filepath || uploaded.path, destPath);
|
|
241
|
+
} catch (errMove) {
|
|
242
|
+
if (errMove.code === 'EXDEV') {
|
|
243
|
+
// Cross-device move: fallback to copy & unlink
|
|
244
|
+
await fsp.copyFile(uploaded.filepath || uploaded.path, destPath);
|
|
245
|
+
await fsp.unlink(uploaded.filepath || uploaded.path);
|
|
246
|
+
} else {
|
|
247
|
+
throw errMove;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Build public URL
|
|
252
|
+
let publicUrl;
|
|
253
|
+
if (settings.ep_images_extended.storage.baseURL) {
|
|
254
|
+
publicUrl = new url.URL(path.posix.join(safePad, newFilename), settings.ep_images_extended.storage.baseURL).toString();
|
|
255
|
+
} else {
|
|
256
|
+
// Default to Etherpad static path assumption
|
|
257
|
+
const relStatic = path.posix.join('/static/images', safePad, newFilename);
|
|
258
|
+
publicUrl = relStatic;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return res.json({ url: publicUrl });
|
|
262
|
+
});
|
|
263
|
+
} catch (e) {
|
|
264
|
+
logger.error('[ep_images_extended] Local upload error', e);
|
|
265
|
+
return res.status(500).json({ error: 'Failed to process upload' });
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
// ---------------------------------------------------------------------
|
|
176
270
|
};
|
|
177
271
|
|
|
178
272
|
/**
|
package/package.json
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ep_images_extended",
|
|
3
3
|
"description": "Insert images inline with text, float them, resize them, and more.",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.4",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "DCastelone",
|
|
7
|
-
"email": "daniel.m.castelone@gmail.com",
|
|
8
7
|
"url": "https://github.com/dcastelone"
|
|
9
8
|
},
|
|
10
9
|
"contributors": [],
|
|
@@ -13,6 +12,15 @@
|
|
|
13
12
|
"@aws-sdk/client-s3": "^3.555.0",
|
|
14
13
|
"@aws-sdk/s3-request-presigner": "^3.555.0"
|
|
15
14
|
},
|
|
15
|
+
"license": "Apache-2.0",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"etherpad",
|
|
18
|
+
"plugin",
|
|
19
|
+
"ep",
|
|
20
|
+
"hyperlink",
|
|
21
|
+
"link",
|
|
22
|
+
"url"
|
|
23
|
+
],
|
|
16
24
|
"repository": {
|
|
17
25
|
"type": "git",
|
|
18
26
|
"url": "git+https://github.com/dcastelone/ep_images_extended.git"
|
package/static/js/clientHooks.js
CHANGED
|
@@ -962,7 +962,7 @@ exports.postAceInit = function (hook, context) {
|
|
|
962
962
|
try {
|
|
963
963
|
const formData = new FormData();
|
|
964
964
|
formData.append('file', file, file.name);
|
|
965
|
-
const
|
|
965
|
+
const uploadResp = await $.ajax({
|
|
966
966
|
type: 'POST',
|
|
967
967
|
url: `${clientVars.padId}/pluginfw/ep_images_extended/upload`,
|
|
968
968
|
data: formData,
|
|
@@ -970,12 +970,19 @@ exports.postAceInit = function (hook, context) {
|
|
|
970
970
|
contentType: false,
|
|
971
971
|
processData: false,
|
|
972
972
|
timeout: 60000,
|
|
973
|
+
dataType: 'json',
|
|
973
974
|
});
|
|
974
975
|
|
|
976
|
+
if (!uploadResp || !uploadResp.url) {
|
|
977
|
+
throw new Error('Invalid upload response');
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const publicUrl = uploadResp.url;
|
|
981
|
+
|
|
975
982
|
const probeImg = new Image();
|
|
976
|
-
probeImg.onload = () => insertIntoPad(
|
|
977
|
-
probeImg.onerror = () => insertIntoPad(
|
|
978
|
-
probeImg.src =
|
|
983
|
+
probeImg.onload = () => insertIntoPad(publicUrl, `${probeImg.naturalWidth}px`, `${probeImg.naturalHeight}px`);
|
|
984
|
+
probeImg.onerror = () => insertIntoPad(publicUrl);
|
|
985
|
+
probeImg.src = publicUrl;
|
|
979
986
|
} catch (err) {
|
|
980
987
|
console.error('[ep_images_extended paste] Server upload failed, falling back to base64:', err);
|
|
981
988
|
insertAsDataUrl(file);
|
package/static/js/toolbar.js
CHANGED
|
@@ -164,11 +164,52 @@ exports.postToolbarInit = (hook, context) => {
|
|
|
164
164
|
const errorTitle = html10n.get('ep_images_extended.error.title');
|
|
165
165
|
$.gritter.add({ title: errorTitle, text: err.message, sticky: true, class_name: 'error' });
|
|
166
166
|
});
|
|
167
|
+
} else if (clientVars.ep_images_extended.storageType === 'local') {
|
|
168
|
+
// -------- Upload to server local storage endpoint --------
|
|
169
|
+
$('#imageUploadModalLoader').addClass('popup-show');
|
|
170
|
+
const formData = new FormData();
|
|
171
|
+
formData.append('file', file, file.name);
|
|
172
|
+
|
|
173
|
+
fetch(`${clientVars.padId}/pluginfw/ep_images_extended/upload`, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
body: formData,
|
|
176
|
+
})
|
|
177
|
+
.then(async (response) => {
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
throw new Error(`Upload failed with status ${response.status}`);
|
|
180
|
+
}
|
|
181
|
+
const data = await response.json();
|
|
182
|
+
if (!data || !data.url) throw new Error('Invalid upload response');
|
|
183
|
+
return data.url;
|
|
184
|
+
})
|
|
185
|
+
.then((publicUrl) => {
|
|
186
|
+
$('#imageUploadModalLoader').removeClass('popup-show');
|
|
187
|
+
const img = new Image();
|
|
188
|
+
img.onload = () => {
|
|
189
|
+
const widthPx = `${img.naturalWidth}px`;
|
|
190
|
+
const heightPx = `${img.naturalHeight}px`;
|
|
191
|
+
context.ace.callWithAce((ace) => {
|
|
192
|
+
ace.ace_doInsertImage(publicUrl, widthPx, heightPx);
|
|
193
|
+
}, 'imgUploadLocal', true);
|
|
194
|
+
};
|
|
195
|
+
img.onerror = () => {
|
|
196
|
+
context.ace.callWithAce((ace) => {
|
|
197
|
+
ace.ace_doInsertImage(publicUrl);
|
|
198
|
+
}, 'imgUploadLocalError', true);
|
|
199
|
+
};
|
|
200
|
+
img.src = publicUrl;
|
|
201
|
+
})
|
|
202
|
+
.catch((err) => {
|
|
203
|
+
console.error('[ep_images_extended toolbar] local upload failed', err);
|
|
204
|
+
$('#imageUploadModalLoader').removeClass('popup-show');
|
|
205
|
+
const errorTitle = html10n.get('ep_images_extended.error.title');
|
|
206
|
+
$.gritter.add({ title: errorTitle, text: err.message, sticky: true, class_name: 'error' });
|
|
207
|
+
});
|
|
167
208
|
} else {
|
|
168
209
|
// Unsupported storage type – show error and abort
|
|
169
210
|
$('#imageUploadModalLoader').removeClass('popup-show');
|
|
170
211
|
const errorTitle = html10n.get('ep_images_extended.error.title');
|
|
171
|
-
const errorText = `Unsupported storageType: ${clientVars.ep_images_extended.storageType}. Only "base64" and "
|
|
212
|
+
const errorText = `Unsupported storageType: ${clientVars.ep_images_extended.storageType}. Only "base64", "s3_presigned", and "local" are supported.`;
|
|
172
213
|
$.gritter.add({ title: errorTitle, text: errorText, sticky: true, class_name: 'error' });
|
|
173
214
|
}
|
|
174
215
|
});
|