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 +33 -0
- package/library.js +398 -11
- package/package.json +1 -1
- package/plugin.json +36 -0
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 =
|
|
298
|
+
const folderPrefix = '';
|
|
131
299
|
const ext = path.extname(originalName);
|
|
132
|
-
const
|
|
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
|
-
//
|
|
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
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
|
}
|