ep_images_extended 1.0.3 → 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
@@ -62,6 +62,22 @@ Create (or merge) an **`ep_images_extended`** block at the root of `settings.jso
62
62
  * `AWS_SECRET_ACCESS_KEY`
63
63
  * `AWS_SESSION_TOKEN` (if using temporary credentials)
64
64
 
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
+
65
81
  ---
66
82
 
67
83
  ## Contributing
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.3",
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
  });