nodebb-plugin-cloudflare-r2 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
@@ -51,3 +51,36 @@ environment:
51
51
  - Hooks: `filter:uploadImage`, `filter:uploadFile`
52
52
  - Validates extensions using the **original filename** (fixes drag&drop tmp-path issues)
53
53
  - Streams uploads to R2 (does not read entire file into RAM)
54
+
55
+
56
+ ## Deleting objects when posts are deleted
57
+
58
+ By default, the plugin deletes objects only when a post is **purged** (permanent deletion). This avoids breaking restored posts.
59
+
60
+ If you really want to delete objects on normal (soft) delete too, set:
61
+
62
+ - `NODEBB_R2_DELETE_ON_SOFT_DELETE=true`
63
+
64
+
65
+ ## Cleaning up objects on topic purge/delete and post edit
66
+
67
+ - When a topic is **purged** (permanently deleted), the plugin will delete all R2 objects referenced by posts in that topic.
68
+ - When a post is **edited**, the plugin will delete R2 objects that were referenced before the edit but are not referenced anymore after the edit (best-effort).
69
+
70
+ Soft delete is not permanent in NodeBB, so object deletion is **disabled by default**.
71
+
72
+ To also delete objects on **soft topic delete**, set:
73
+
74
+ - `NODEBB_R2_DELETE_ON_TOPIC_SOFT_DELETE=true`
75
+
76
+ (For soft post delete, see `NODEBB_R2_DELETE_ON_SOFT_DELETE`.)
77
+
78
+
79
+ ## Cleaning up avatars (profile pictures)
80
+
81
+ When a user changes their avatar/profile picture, the plugin will attempt to delete the previously referenced R2 object (only if the old URL points to your `NODEBB_R2_HOST`/`NODEBB_R2_UPLOAD_PATH`). This is best-effort.
82
+
83
+
84
+ ## Multi-instance / cluster reliability
85
+
86
+ In multi-instance deployments, cleanup on post edit and avatar changes uses the shared NodeBB database to store a short-lived "before" snapshot (TTL). This keeps cleanup reliable even when different instances handle the before/after hooks.
package/library.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * - Configuration via environment variables
8
8
  */
9
9
 
10
- const { S3Client } = require('@aws-sdk/client-s3');
10
+ const { S3Client, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
11
11
  const { Upload } = require('@aws-sdk/lib-storage');
12
12
 
13
13
  const fs = require('fs');
@@ -18,8 +18,19 @@ const mime = require('mime-types');
18
18
  const winston = require.main.require('winston');
19
19
  const meta = require.main.require('./src/meta');
20
20
  const fileModule = require.main.require('./src/file');
21
+ const Posts = require.main.require('./src/posts');
22
+ const Topics = require.main.require('./src/topics');
23
+ const Users = require.main.require('./src/user');
24
+ const db = require.main.require('./src/database');
21
25
 
22
26
  const plugin = {};
27
+
28
+ // In multi-instance deployments, we store "before" snapshots in the shared NodeBB DB.
29
+ // As a fallback (if DB ops fail), we keep a tiny in-memory cache.
30
+ const _fallbackEditCache = new Map();
31
+ const _fallbackAvatarCache = new Map();
32
+ const EDIT_CACHE_TTL_SEC = 5 * 60;
33
+ const AVATAR_CACHE_TTL_SEC = 10 * 60;
23
34
  plugin._s3 = null;
24
35
 
25
36
  function env(name, def = '') {
@@ -48,6 +59,22 @@ function normalizeFolder(folder) {
48
59
  return f ? `${f}/` : '';
49
60
  }
50
61
 
62
+ function slugifyBase(name) {
63
+ const base = String(name || '').trim();
64
+ if (!base) return 'file';
65
+ // Remove extension if present
66
+ const ext = path.extname(base);
67
+ const stem = ext ? base.slice(0, -ext.length) : base;
68
+ // Basic slug: keep letters/numbers, replace others with '-'
69
+ const slug = stem
70
+ .normalize('NFKD')
71
+ .replace(/[\u0300-\u036f]/g, '')
72
+ .replace(/[^a-zA-Z0-9]+/g, '-')
73
+ .replace(/^-+|-+$/g, '')
74
+ .toLowerCase();
75
+ return slug || 'file';
76
+ }
77
+
51
78
  function getSettings() {
52
79
  // Env-only by design
53
80
  return {
@@ -100,6 +127,147 @@ function isExtensionAllowed(originalName, allowed) {
100
127
  );
101
128
  }
102
129
 
130
+
131
+
132
+ function _nowSec() { return Math.floor(Date.now() / 1000); }
133
+
134
+ async function dbSetWithTTL(key, valueObj, ttlSec) {
135
+ // NodeBB DB API differs slightly by version/adapters; do best-effort.
136
+ try {
137
+ if (db && typeof db.setObject === 'function') {
138
+ await db.setObject(key, valueObj);
139
+ } else if (db && typeof db.setObjectField === 'function') {
140
+ // store JSON in a field
141
+ await db.setObjectField(key, 'value', JSON.stringify(valueObj));
142
+ } else if (db && typeof db.set === 'function') {
143
+ await db.set(key, JSON.stringify(valueObj));
144
+ } else {
145
+ throw new Error('No compatible db set method');
146
+ }
147
+
148
+ if (db && typeof db.expire === 'function') {
149
+ await db.expire(key, ttlSec);
150
+ } else if (db && typeof db.expireAt === 'function') {
151
+ await db.expireAt(key, _nowSec() + ttlSec);
152
+ }
153
+ return true;
154
+ } catch (e) {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ async function dbGet(key) {
160
+ try {
161
+ if (db && typeof db.getObject === 'function') {
162
+ return await db.getObject(key);
163
+ }
164
+ if (db && typeof db.getObjectField === 'function') {
165
+ const v = await db.getObjectField(key, 'value');
166
+ return v ? JSON.parse(v) : null;
167
+ }
168
+ if (db && typeof db.get === 'function') {
169
+ const v = await db.get(key);
170
+ return v ? JSON.parse(v) : null;
171
+ }
172
+ return null;
173
+ } catch (e) {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ async function dbDel(key) {
179
+ try {
180
+ if (db && typeof db.deleteObject === 'function') {
181
+ await db.deleteObject(key);
182
+ return;
183
+ }
184
+ if (db && typeof db.delete === 'function') {
185
+ await db.delete(key);
186
+ return;
187
+ }
188
+ if (db && typeof db.remove === 'function') {
189
+ await db.remove(key);
190
+ return;
191
+ }
192
+ } catch (e) { /* swallow */ }
193
+ }
194
+
195
+ function pruneFallbackCache(map, ttlSec) {
196
+ const now = _nowSec();
197
+ for (const [k, v] of map.entries()) {
198
+ if (!v || (now - (v.ts || 0)) > ttlSec) map.delete(k);
199
+ }
200
+ }
201
+
202
+ function editCacheKey(pid) {
203
+ return `r2:edit:${pid}`;
204
+ }
205
+
206
+ function avatarCacheKey(uid) {
207
+ return `r2:avatar:${uid}`;
208
+ }
209
+
210
+ function parseHostBase(s) {
211
+ const host = (s.host || '').trim();
212
+ if (!host) return '';
213
+ return host.replace(/\/+$/, '');
214
+ }
215
+
216
+ function extractR2KeysFromContent(s, content) {
217
+ const hostBase = parseHostBase(s);
218
+ const prefix = normalizePrefix(s.uploadPath).replace(/\/+$/, ''); // 'forum' or ''
219
+ const keys = new Set();
220
+
221
+ const text = String(content || '');
222
+
223
+ // Match absolute URLs to our host
224
+ if (hostBase) {
225
+ const escaped = hostBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
226
+ const reAbs = new RegExp(escaped + '\\/([^\\s"\'\\)\\]]+)', 'gi');
227
+ let m;
228
+ while ((m = reAbs.exec(text)) !== null) {
229
+ const k = decodeURIComponent(m[1].split('?')[0].split('#')[0]);
230
+ if (!prefix || k === prefix || k.startsWith(prefix + '/')) {
231
+ keys.add(k);
232
+ }
233
+ }
234
+ }
235
+
236
+ // Match relative URLs like /forum/xxx if people paste relative links
237
+ if (prefix) {
238
+ const reRel = new RegExp('\\/(' + prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\/[^\\s"\'\\)\\]]+)', 'gi');
239
+ let m;
240
+ while ((m = reRel.exec(text)) !== null) {
241
+ const k = decodeURIComponent(m[1].split('?')[0].split('#')[0]);
242
+ keys.add(k);
243
+ }
244
+ }
245
+
246
+ return Array.from(keys);
247
+ }
248
+
249
+ async function deleteKeysFromR2(s, keys) {
250
+ if (!keys || !keys.length) return;
251
+ const client = getS3Client(s);
252
+
253
+ // DeleteObjects supports up to 1000 keys per request
254
+ const chunks = [];
255
+ for (let i = 0; i < keys.length; i += 1000) chunks.push(keys.slice(i, i + 1000));
256
+
257
+ for (const chunk of chunks) {
258
+ await client.send(new DeleteObjectsCommand({
259
+ Bucket: s.bucket,
260
+ Delete: { Objects: chunk.map(Key => ({ Key })), Quiet: true },
261
+ }));
262
+ }
263
+ }
264
+
265
+ function pify(fn, ctx, ...args) {
266
+ return new Promise((resolve, reject) => {
267
+ fn.call(ctx, ...args, (err, res) => (err ? reject(err) : resolve(res)));
268
+ });
269
+ }
270
+
103
271
  function publicUrlForKey(s, key) {
104
272
  let host = (s.host || '').trim();
105
273
  if (!host) {
@@ -127,9 +295,10 @@ async function uploadFromPath({ s, filePath, originalName, folder }) {
127
295
  }
128
296
 
129
297
  const prefix = normalizePrefix(s.uploadPath);
130
- const folderPrefix = normalizeFolder(folder);
298
+ const folderPrefix = '';
131
299
  const ext = path.extname(originalName);
132
- const key = `${prefix}${folderPrefix}${uuidv4()}${ext}`;
300
+ const baseSlug = slugifyBase(originalName);
301
+ const key = `${prefix}${folderPrefix}${baseSlug}-${uuidv4()}${ext}`;
133
302
 
134
303
  const contentType = mime.lookup(originalName) || 'application/octet-stream';
135
304
 
@@ -159,14 +328,7 @@ async function uploadFromPath({ s, filePath, originalName, folder }) {
159
328
  }
160
329
 
161
330
  plugin.init = async () => {
162
- // Validate early and log helpful info (without secrets)
163
- try {
164
- const s = getSettings();
165
- assertConfigured(s);
166
- winston.info(`[cloudflare-r2] enabled (bucket=${s.bucket}, endpoint=${s.endpoint}, region=${s.region}, host=${s.host || '(default)'}, forcePathStyle=${s.forcePathStyle})`);
167
- } catch (e) {
168
- winston.warn(`[cloudflare-r2] not configured: ${e.message}`);
169
- }
331
+ // No startup logs; configuration errors will surface on upload attempts.
170
332
  };
171
333
 
172
334
  plugin.uploadImage = async (data) => {
@@ -228,4 +390,229 @@ function createError(err) {
228
390
  return e;
229
391
  }
230
392
 
393
+
394
+ plugin.onPostsPurge = async (data) => {
395
+ // Purge = permanent deletion; safe to remove associated objects
396
+ try {
397
+ const s = getSettings();
398
+ assertConfigured(s);
399
+
400
+ // data can be a pid, an array of pids, or an object, depending on NodeBB code path
401
+ const pids = Array.isArray(data) ? data : (data && data.pids ? data.pids : (data && data.pid ? [data.pid] : (typeof data === 'number' || typeof data === 'string' ? [data] : [])));
402
+
403
+ for (const pid of pids) {
404
+ const post = await pify(Posts.getPostFields, Posts, pid, ['content']);
405
+ const keys = extractR2KeysFromContent(s, post && post.content);
406
+ await deleteKeysFromR2(s, keys);
407
+ }
408
+ } catch (e) {
409
+ // Avoid noisy logs; failures to delete should not break NodeBB purge flow
410
+ }
411
+ };
412
+
413
+ plugin.onPostDelete = async (pidOrData) => {
414
+ // Soft delete: by default DO NOT delete objects (post can be restored)
415
+ const shouldDelete = (String(process.env.NODEBB_R2_DELETE_ON_SOFT_DELETE || '').toLowerCase() === 'true');
416
+ if (!shouldDelete) return;
417
+
418
+ try {
419
+ const s = getSettings();
420
+ assertConfigured(s);
421
+
422
+ const pid = (pidOrData && pidOrData.pid) ? pidOrData.pid : pidOrData;
423
+ if (!pid) return;
424
+
425
+ const post = await pify(Posts.getPostFields, Posts, pid, ['content']);
426
+ const keys = extractR2KeysFromContent(s, post && post.content);
427
+ await deleteKeysFromR2(s, keys);
428
+ } catch (e) {
429
+ // swallow
430
+ }
431
+ };
432
+
433
+
434
+ plugin.onPostEditFilter = async (hookData) => {
435
+ // Capture keys before the edit is committed (best-effort, in-memory)
436
+ try {
437
+ pruneEditCache();
438
+ const s = getSettings();
439
+ assertConfigured(s);
440
+
441
+ const pid = hookData && hookData.post && hookData.post.pid ? hookData.post.pid : (hookData && hookData.pid ? hookData.pid : null);
442
+ if (!pid) return hookData;
443
+
444
+ const post = await pify(Posts.getPostFields, Posts, pid, ['content']);
445
+ const keys = extractR2KeysFromContent(s, post && post.content);
446
+ const key = editCacheKey(pid);
447
+ const payload = { keys, ts: _nowSec() };
448
+ const ok = await dbSetWithTTL(key, payload, EDIT_CACHE_TTL_SEC);
449
+ if (!ok) {
450
+ _fallbackEditCache.set(String(pid), { keys: new Set(keys), ts: _nowSec() });
451
+ }
452
+ } catch (e) {
453
+ // swallow
454
+ }
455
+ return hookData;
456
+ };
457
+
458
+ plugin.onPostEditAction = async (hookData) => {
459
+ // After edit is saved, delete objects that were referenced before but not anymore
460
+ try {
461
+ pruneEditCache();
462
+ const s = getSettings();
463
+ assertConfigured(s);
464
+
465
+ const pid = hookData && hookData.post && hookData.post.pid ? hookData.post.pid : (hookData && hookData.pid ? hookData.pid : null);
466
+ if (!pid) return;
467
+
468
+ const pidKey = String(pid);
469
+ const before = _editCache.get(pidKey);
470
+ _editCache.delete(pidKey);
471
+
472
+ if (!before || !before.keys || before.keys.size === 0) return;
473
+
474
+ const post = await pify(Posts.getPostFields, Posts, pid, ['content']);
475
+ const afterKeysArr = extractR2KeysFromContent(s, post && post.content);
476
+ const afterSet = new Set(afterKeysArr);
477
+
478
+ const toDelete = [];
479
+ for (const k of beforeKeys) {
480
+ if (!afterSet.has(k)) toDelete.push(k);
481
+ }
482
+ await deleteKeysFromR2(s, toDelete);
483
+ } catch (e) {
484
+ // swallow
485
+ }
486
+ };
487
+
488
+ plugin.onTopicPurge = async (data) => {
489
+ // Permanent deletion of a topic -> delete all objects referenced by posts in the topic
490
+ try {
491
+ const s = getSettings();
492
+ assertConfigured(s);
493
+
494
+ const tid = (data && data.tid) ? data.tid : data;
495
+ if (!tid) return;
496
+
497
+ const pids = await pify(Topics.getPids, Topics, tid);
498
+ if (!pids || !pids.length) return;
499
+
500
+ // Fetch contents in chunks to avoid huge memory
501
+ const allKeys = new Set();
502
+ for (let i = 0; i < pids.length; i += 50) {
503
+ const chunk = pids.slice(i, i + 50);
504
+ const posts = await pify(Posts.getPostsFields, Posts, chunk, ['content']);
505
+ for (const post of (posts || [])) {
506
+ const keys = extractR2KeysFromContent(s, post && post.content);
507
+ for (const k of keys) allKeys.add(k);
508
+ }
509
+ }
510
+
511
+ await deleteKeysFromR2(s, Array.from(allKeys));
512
+ } catch (e) {
513
+ // swallow
514
+ }
515
+ };
516
+
517
+ plugin.onTopicDelete = async (data) => {
518
+ // Soft delete a topic: optional deletion of objects
519
+ const shouldDelete = (String(process.env.NODEBB_R2_DELETE_ON_TOPIC_SOFT_DELETE || '').toLowerCase() === 'true');
520
+ if (!shouldDelete) return;
521
+
522
+ try {
523
+ const s = getSettings();
524
+ assertConfigured(s);
525
+
526
+ const tid = (data && data.tid) ? data.tid : data;
527
+ if (!tid) return;
528
+
529
+ const pids = await pify(Topics.getPids, Topics, tid);
530
+ if (!pids || !pids.length) return;
531
+
532
+ const allKeys = new Set();
533
+ for (let i = 0; i < pids.length; i += 50) {
534
+ const chunk = pids.slice(i, i + 50);
535
+ const posts = await pify(Posts.getPostsFields, Posts, chunk, ['content']);
536
+ for (const post of (posts || [])) {
537
+ const keys = extractR2KeysFromContent(s, post && post.content);
538
+ for (const k of keys) allKeys.add(k);
539
+ }
540
+ }
541
+
542
+ await deleteKeysFromR2(s, Array.from(allKeys));
543
+ } catch (e) {
544
+ // swallow
545
+ }
546
+ };
547
+
548
+
549
+ plugin.onUserUpdateProfileFilter = async (hookData) => {
550
+ // Capture current picture before profile update (best-effort)
551
+ try {
552
+ pruneAvatarCache();
553
+ const s = getSettings();
554
+ assertConfigured(s);
555
+
556
+ const uid = hookData && (hookData.uid || (hookData.user && hookData.user.uid));
557
+ if (!uid) return hookData;
558
+
559
+ const user = await pify(Users.getUserFields, Users, uid, ['picture']);
560
+ const picture = user && user.picture ? String(user.picture) : '';
561
+ const keys = extractR2KeysFromContent(s, picture);
562
+ const key = avatarCacheKey(uid);
563
+ const payload = { picture, keys, ts: _nowSec() };
564
+ const ok = await dbSetWithTTL(key, payload, AVATAR_CACHE_TTL_SEC);
565
+ if (!ok) {
566
+ _fallbackAvatarCache.set(String(uid), { picture, keys: new Set(keys), ts: _nowSec() });
567
+ }
568
+ } catch (e) {
569
+ // swallow
570
+ }
571
+ return hookData;
572
+ };
573
+
574
+ plugin.onUserUpdateProfileAction = async (hookData) => {
575
+ // After profile update, if picture changed, delete old uploaded picture (if it was ours)
576
+ try {
577
+ pruneAvatarCache();
578
+ const s = getSettings();
579
+ assertConfigured(s);
580
+
581
+ const uid = hookData && (hookData.uid || (hookData.user && hookData.user.uid));
582
+ if (!uid) return;
583
+
584
+ const uidKey = String(uid);
585
+ const before = _avatarCache.get(uidKey);
586
+ _avatarCache.delete(uidKey);
587
+ if (!before || !before.keys || before.keys.size === 0) return;
588
+
589
+ const user = await pify(Users.getUserFields, Users, uid, ['picture']);
590
+ const afterPic = user && user.picture ? String(user.picture) : '';
591
+ const afterKeys = new Set(extractR2KeysFromContent(s, afterPic));
592
+
593
+ const toDelete = [];
594
+ for (const k of beforeKeys) {
595
+ if (!afterKeys.has(k)) toDelete.push(k);
596
+ }
597
+ await deleteKeysFromR2(s, toDelete);
598
+ } catch (e) {
599
+ // swallow
600
+ }
601
+ };
602
+
603
+ plugin.onUserRemoveUploadedPicture = async (hookData) => {
604
+ // When user removes uploaded picture explicitly, try to delete it from R2
605
+ try {
606
+ const s = getSettings();
607
+ assertConfigured(s);
608
+
609
+ // Hook payload varies; try common fields
610
+ const picture = (hookData && (hookData.picture || hookData.url)) ? String(hookData.picture || hookData.url) : '';
611
+ const keys = extractR2KeysFromContent(s, picture);
612
+ await deleteKeysFromR2(s, keys);
613
+ } catch (e) {
614
+ // swallow
615
+ }
616
+ };
617
+
231
618
  module.exports = plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-cloudflare-r2",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "NodeBB v4 upload provider for Cloudflare R2 (S3-compatible) using environment variables",
5
5
  "main": "library.js",
6
6
  "author": "You",
package/plugin.json CHANGED
@@ -18,6 +18,42 @@
18
18
  "hook": "filter:uploadFile",
19
19
  "method": "uploadFile",
20
20
  "priority": 1
21
+ },
22
+ {
23
+ "hook": "action:posts.purge",
24
+ "method": "onPostsPurge"
25
+ },
26
+ {
27
+ "hook": "action:post.delete",
28
+ "method": "onPostDelete"
29
+ },
30
+ {
31
+ "hook": "filter:post.edit",
32
+ "method": "onPostEditFilter"
33
+ },
34
+ {
35
+ "hook": "action:post.edit",
36
+ "method": "onPostEditAction"
37
+ },
38
+ {
39
+ "hook": "action:topic.purge",
40
+ "method": "onTopicPurge"
41
+ },
42
+ {
43
+ "hook": "action:topic.delete",
44
+ "method": "onTopicDelete"
45
+ },
46
+ {
47
+ "hook": "filter:user.updateProfile",
48
+ "method": "onUserUpdateProfileFilter"
49
+ },
50
+ {
51
+ "hook": "action:user.updateProfile",
52
+ "method": "onUserUpdateProfileAction"
53
+ },
54
+ {
55
+ "hook": "action:user.removeUploadedPicture",
56
+ "method": "onUserRemoveUploadedPicture"
21
57
  }
22
58
  ]
23
59
  }