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 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
+ ![Demo](https://i.imgur.com/uTpL9Za.png)
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
- "ep_images_extended": {
34
- "fileTypes": ["jpeg", "jpg", "png", "gif", "bmp", "webp"],
35
- "maxFileSize": 5000000
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
- ## Option reference – quick lookup
65
-
66
- ```text
67
- fileTypes ↠ Allowed extensions (array of strings)
68
- maxFileSize ↠ Maximum upload size in bytes
69
- storage.type ↠ "base64" | "s3_presigned"
70
- ├─ bucket ↠ S3 bucket name (s3_presigned only)
71
- ├─ region ↠ AWS region (s3_presigned only)
72
- ├─ publicURL ↠ CDN / CloudFront base URL (optional)
73
- └─ expires ↠ URL lifetime in seconds (optional)
74
- ```
75
- All other values are ignored.
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
- * Only active when settings.ep_images_extended.storage.type === 's3_presigned'
106
+ * Register the route only when storage.type === 's3_presigned'
107
107
  */
108
- context.app.get('/p/:padId/pluginfw/ep_images_extended/s3_presign', async (req, res) => {
109
- /* ------------------ Basic auth check ------------------ */
110
- const hasExpressSession = req.session && (req.session.user || req.session.authorId);
111
- const hasPadCookie = req.cookies && (req.cookies.sessionID || req.cookies.token);
112
- if (!hasExpressSession && !hasPadCookie) {
113
- return res.status(401).json({ error: 'Authentication required' });
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
- if (!S3Client || !PutObjectCommand || !getSignedUrl) {
129
- return res.status(500).json({ error: 'AWS SDK not available on server' });
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
- const { bucket, region, publicURL, expires } = storageCfg;
133
- if (!bucket || !region) {
134
- return res.status(500).json({ error: 'Invalid S3 configuration' });
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
- const { padId } = req.params;
138
- const { name, type } = req.query;
139
- if (!name || !type) {
140
- return res.status(400).json({ error: 'Missing name or type' });
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
- /* ------------- MIME / extension allow-list ------------ */
144
- if (settings.ep_images_extended && settings.ep_images_extended.fileTypes && Array.isArray(settings.ep_images_extended.fileTypes)) {
145
- const allowedExts = settings.ep_images_extended.fileTypes;
146
- const extName = path.extname(name).replace('.', '').toLowerCase();
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
- const ext = path.extname(name);
153
- // Ensure ext starts with '.'; if not, prefix it
154
- const safeExt = ext.startsWith('.') ? ext : `.${ext}`;
155
- const key = `${padId}/${randomUUID()}${safeExt}`;
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
- const s3Client = new S3Client({ region }); // credentials from env / IAM role
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
- const putCommand = new PutObjectCommand({
160
- Bucket: bucket,
161
- Key: key,
162
- ContentType: type,
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
- const signedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: expires || 600 });
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
- const basePublic = publicURL || `https://${bucket}.s3.${region}.amazonaws.com/`;
168
- const publicUrl = new url.URL(key, basePublic).toString();
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
- return res.json({ signedUrl, publicUrl });
171
- } catch (err) {
172
- logger.error('[ep_images_extended] S3 presign error', err);
173
- return res.status(500).json({ error: 'Failed to generate presigned URL' });
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.2",
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"
@@ -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 uploadUrl = await $.ajax({
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(uploadUrl, `${probeImg.naturalWidth}px`, `${probeImg.naturalHeight}px`);
977
- probeImg.onerror = () => insertIntoPad(uploadUrl);
978
- probeImg.src = uploadUrl;
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);
@@ -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 "s3_presigned" are supported.`;
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
  });