nodebb-plugin-integration 0.4.4 → 0.6.0

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/src/routes/api.js CHANGED
@@ -16,13 +16,17 @@ function validId(value) {
16
16
  return Number.isSafeInteger(id) && id > 0 ? id : 0;
17
17
  }
18
18
 
19
+ function validPage(value, fallback, maximum) {
20
+ const number = value === undefined ? fallback : Number(value);
21
+ return Number.isSafeInteger(number) && number >= 0 && number <= maximum
22
+ ? number : -1;
23
+ }
24
+
19
25
  function internalTokenMatches(req, expected) {
20
26
  const authorization = req.headers.authorization || "";
21
27
  const match = authorization.match(/^Bearer\s+([^\s]+)$/i);
22
28
 
23
- if (!match || typeof expected !== "string" || !expected) {
24
- return false;
25
- }
29
+ if (!match || typeof expected !== "string" || !expected) return false;
26
30
 
27
31
  const actualBuffer = Buffer.from(match[1]);
28
32
  const expectedBuffer = Buffer.from(expected);
@@ -31,6 +35,17 @@ function internalTokenMatches(req, expected) {
31
35
  crypto.timingSafeEqual(actualBuffer, expectedBuffer);
32
36
  }
33
37
 
38
+ function serviceUid(req) {
39
+ return validId(req.integrationSettings?.mcpServiceUid);
40
+ }
41
+
42
+ function operationError(res, error, label) {
43
+ console.error(`[Integration] ${label}:`, error.stack || error);
44
+ return res.status(400).json({
45
+ error: error.message || `${label} failed`
46
+ });
47
+ }
48
+
34
49
  exports.internalAuth = async function (req, res, next) {
35
50
  try {
36
51
  const settings = await Config.get();
@@ -46,7 +61,7 @@ exports.internalAuth = async function (req, res, next) {
46
61
  req.integrationSettings = settings;
47
62
  return next();
48
63
  } catch (error) {
49
- console.error("[Integration] Internal authentication failed:", error.message);
64
+ console.error("[Integration] Internal authentication failed:", error);
50
65
  return res.status(500).json({ error: "Internal API error" });
51
66
  }
52
67
  };
@@ -54,15 +69,13 @@ exports.internalAuth = async function (req, res, next) {
54
69
  exports.apiSearch = async function (req, res) {
55
70
  try {
56
71
  const query = typeof req.body?.query === "string"
57
- ? req.body.query.trim()
58
- : "";
72
+ ? req.body.query.trim() : "";
59
73
 
60
74
  if (!query || query.length > 2000) {
61
75
  return res.status(400).json({ error: "Invalid search query" });
62
76
  }
63
77
 
64
78
  const results = await Search.searchText(query, req.uid);
65
-
66
79
  return res.json({ results });
67
80
  } catch (error) {
68
81
  if (error.code === "AI_DISABLED") {
@@ -78,7 +91,7 @@ exports.internalInfo = async function (req, res) {
78
91
  try {
79
92
  return res.json(await Meta.getInfo());
80
93
  } catch (error) {
81
- console.error("[Integration] Forum info failed:", error.message);
94
+ console.error("[Integration] Forum info failed:", error);
82
95
  return res.status(500).json({ error: "Unable to get forum information" });
83
96
  }
84
97
  };
@@ -86,10 +99,10 @@ exports.internalInfo = async function (req, res) {
86
99
  exports.internalPost = async function (req, res) {
87
100
  try {
88
101
  const pid = validId(req.body?.pid);
89
- const uid = req.integrationSettings.mcpServiceUid;
102
+ const uid = serviceUid(req);
90
103
 
91
- if (!pid) {
92
- return res.status(400).json({ error: "Invalid post ID" });
104
+ if (!pid || !uid) {
105
+ return res.status(400).json({ error: "Invalid post ID or service user" });
93
106
  }
94
107
 
95
108
  const post = await Posts.getPostMeta(pid);
@@ -100,9 +113,7 @@ exports.internalPost = async function (req, res) {
100
113
 
101
114
  const tid = validId(post.tid);
102
115
 
103
- if (!tid) {
104
- return res.status(404).json({ error: "Post not found" });
105
- }
116
+ if (!tid) return res.status(404).json({ error: "Post not found" });
106
117
 
107
118
  const [postAllowed, topicAllowed] = await Promise.all([
108
119
  Privileges.canReadPost(pid, uid),
@@ -115,9 +126,7 @@ exports.internalPost = async function (req, res) {
115
126
 
116
127
  const content = await Posts.getPostContent(pid);
117
128
 
118
- if (!content) {
119
- return res.status(404).json({ error: "Post not found" });
120
- }
129
+ if (!content) return res.status(404).json({ error: "Post not found" });
121
130
 
122
131
  return res.json({
123
132
  pid: Number(post.pid),
@@ -128,7 +137,7 @@ exports.internalPost = async function (req, res) {
128
137
  timestamp: post.timestamp
129
138
  });
130
139
  } catch (error) {
131
- console.error("[Integration] MCP post retrieval failed:", error.message);
140
+ console.error("[Integration] MCP post retrieval failed:", error);
132
141
  return res.status(500).json({ error: "Unable to get post" });
133
142
  }
134
143
  };
@@ -136,10 +145,10 @@ exports.internalPost = async function (req, res) {
136
145
  exports.internalTopic = async function (req, res) {
137
146
  try {
138
147
  const tid = validId(req.body?.tid);
139
- const uid = req.integrationSettings.mcpServiceUid;
148
+ const uid = serviceUid(req);
140
149
 
141
- if (!tid) {
142
- return res.status(400).json({ error: "Invalid topic ID" });
150
+ if (!tid || !uid) {
151
+ return res.status(400).json({ error: "Invalid topic ID or service user" });
143
152
  }
144
153
 
145
154
  if (!(await Privileges.canReadTopic(tid, uid))) {
@@ -161,7 +170,7 @@ exports.internalTopic = async function (req, res) {
161
170
  mainPid: Number(topic.mainPid)
162
171
  });
163
172
  } catch (error) {
164
- console.error("[Integration] MCP topic retrieval failed:", error.message);
173
+ console.error("[Integration] MCP topic retrieval failed:", error);
165
174
  return res.status(500).json({ error: "Unable to get topic" });
166
175
  }
167
176
  };
@@ -170,9 +179,7 @@ exports.internalUser = async function (req, res) {
170
179
  try {
171
180
  const uid = validId(req.body?.uid);
172
181
 
173
- if (!uid) {
174
- return res.status(400).json({ error: "Invalid user ID" });
175
- }
182
+ if (!uid) return res.status(400).json({ error: "Invalid user ID" });
176
183
 
177
184
  const user = await Users.getUser(uid);
178
185
 
@@ -187,7 +194,7 @@ exports.internalUser = async function (req, res) {
187
194
  picture: user.picture
188
195
  });
189
196
  } catch (error) {
190
- console.error("[Integration] MCP user retrieval failed:", error.message);
197
+ console.error("[Integration] MCP user retrieval failed:", error);
191
198
  return res.status(500).json({ error: "Unable to get user" });
192
199
  }
193
200
  };
@@ -195,9 +202,7 @@ exports.internalUser = async function (req, res) {
195
202
  exports.internalSearch = async function (req, res) {
196
203
  try {
197
204
  const query = typeof req.body?.query === "string"
198
- ? req.body.query.trim()
199
- : "";
200
-
205
+ ? req.body.query.trim() : "";
201
206
  const requestedLimit = Number(req.body?.limit ?? 5);
202
207
 
203
208
  if (!query || query.length > 2000) {
@@ -208,10 +213,11 @@ exports.internalSearch = async function (req, res) {
208
213
  return res.status(400).json({ error: "Invalid search limit" });
209
214
  }
210
215
 
211
- const limit = Math.min(requestedLimit, 20);
212
- const uid = req.integrationSettings.mcpServiceUid;
213
-
214
- const results = await Search.searchText(query, uid, limit);
216
+ const results = await Search.searchText(
217
+ query,
218
+ serviceUid(req),
219
+ Math.min(requestedLimit, 20)
220
+ );
215
221
 
216
222
  return res.json({ results });
217
223
  } catch (error) {
@@ -219,11 +225,268 @@ exports.internalSearch = async function (req, res) {
219
225
  return res.status(503).json({ error: error.message });
220
226
  }
221
227
 
222
- console.error("[Integration] MCP search failed:", error.message);
228
+ console.error("[Integration] MCP search failed:", error);
223
229
  return res.status(500).json({ error: "Search failed" });
224
230
  }
225
231
  };
226
232
 
233
+ exports.internalCreateTopic = async function (req, res) {
234
+ try {
235
+ const uid = serviceUid(req);
236
+ const cid = validId(req.body?.cid);
237
+ const title = req.body?.title;
238
+ const content = req.body?.content;
239
+ const tags = req.body?.tags ?? [];
240
+
241
+ if (!uid || !cid || typeof title !== "string" ||
242
+ !title.trim() || title.length > 255 ||
243
+ typeof content !== "string" || !content.trim() ||
244
+ content.length > 20000 || !Array.isArray(tags) ||
245
+ tags.length > 10 ||
246
+ tags.some(tag => typeof tag !== "string" || !tag.trim() || tag.length > 32)) {
247
+ return res.status(400).json({ error: "Invalid topic data" });
248
+ }
249
+
250
+ const result = await Topics.createTopic({
251
+ uid,
252
+ cid,
253
+ title: title.trim(),
254
+ content,
255
+ tags
256
+ });
257
+
258
+ return res.json(result);
259
+ } catch (error) {
260
+ return operationError(res, error, "Topic creation");
261
+ }
262
+ };
263
+
264
+ exports.internalDeleteTopic = async function (req, res) {
265
+ try {
266
+ const tid = validId(req.body?.tid);
267
+ const uid = serviceUid(req);
268
+
269
+ if (!tid || !uid) return res.status(400).json({ error: "Invalid topic ID" });
270
+
271
+ return res.json(await Topics.deleteTopic(tid, uid));
272
+ } catch (error) {
273
+ return operationError(res, error, "Topic deletion");
274
+ }
275
+ };
276
+
277
+ exports.internalRestoreTopic = async function (req, res) {
278
+ try {
279
+ const tid = validId(req.body?.tid);
280
+ const uid = serviceUid(req);
281
+
282
+ if (!tid || !uid) return res.status(400).json({ error: "Invalid topic ID" });
283
+
284
+ return res.json(await Topics.restoreTopic(tid, uid));
285
+ } catch (error) {
286
+ return operationError(res, error, "Topic restoration");
287
+ }
288
+ };
289
+
290
+ exports.internalListTopics = async function (req, res) {
291
+ try {
292
+ const uid = serviceUid(req);
293
+ const start = validPage(req.body?.start, 0, 10000000);
294
+ const limit = validPage(req.body?.limit, 20, 100);
295
+
296
+ const sorts = ["recent", "old", "create", "posts", "votes", "views"];
297
+ const sort = req.body?.sort ?? "recent";
298
+ const cid = req.body?.cid === undefined ? undefined : validId(req.body.cid);
299
+
300
+ if (!uid || start < 0 || limit < 1 || !sorts.includes(sort) ||
301
+ (req.body?.cid !== undefined && !cid)) {
302
+ return res.status(400).json({ error: "Invalid topic listing parameters" });
303
+ }
304
+
305
+ const result = await Topics.listTopics({
306
+ uid,
307
+ start,
308
+ stop: start + limit - 1,
309
+ sort,
310
+ cid
311
+ });
312
+
313
+ const nextStart = Math.min(start + limit, result.topicCount);
314
+
315
+ return res.json({
316
+ ...result,
317
+ nextStart,
318
+ hasMore: nextStart < result.topicCount
319
+ });
320
+ } catch (error) {
321
+ return operationError(res, error, "Topic listing");
322
+ }
323
+ };
324
+
325
+ exports.internalCreatePost = async function (req, res) {
326
+ try {
327
+ const uid = serviceUid(req);
328
+ const tid = validId(req.body?.tid);
329
+ const content = req.body?.content;
330
+
331
+ if (!uid || !tid || typeof content !== "string" ||
332
+ !content.trim() || content.length > 20000) {
333
+ return res.status(400).json({ error: "Invalid post data" });
334
+ }
335
+
336
+ return res.json(await Topics.createReply({ uid, tid, content }));
337
+ } catch (error) {
338
+ return operationError(res, error, "Post creation");
339
+ }
340
+ };
341
+
342
+ exports.internalEditPost = async function (req, res) {
343
+ try {
344
+ const uid = serviceUid(req);
345
+ const pid = validId(req.body?.pid);
346
+ const content = req.body?.content;
347
+
348
+ if (!uid || !pid || typeof content !== "string" ||
349
+ !content.trim() || content.length > 20000) {
350
+ return res.status(400).json({ error: "Invalid post data" });
351
+ }
352
+
353
+ return res.json(await Posts.editPost(pid, uid, content));
354
+ } catch (error) {
355
+ return operationError(res, error, "Post editing");
356
+ }
357
+ };
358
+
359
+ exports.internalDeletePost = async function (req, res) {
360
+ try {
361
+ const uid = serviceUid(req);
362
+ const pid = validId(req.body?.pid);
363
+
364
+ if (!uid || !pid) return res.status(400).json({ error: "Invalid post ID" });
365
+
366
+ return res.json(await Posts.deletePost(pid, uid));
367
+ } catch (error) {
368
+ return operationError(res, error, "Post deletion");
369
+ }
370
+ };
371
+
372
+ exports.internalRestorePost = async function (req, res) {
373
+ try {
374
+ const uid = serviceUid(req);
375
+ const pid = validId(req.body?.pid);
376
+
377
+ if (!uid || !pid) return res.status(400).json({ error: "Invalid post ID" });
378
+
379
+ return res.json(await Posts.restorePost(pid, uid));
380
+ } catch (error) {
381
+ return operationError(res, error, "Post restoration");
382
+ }
383
+ };
384
+
385
+ exports.internalDeleteUserPosts = async function (req, res) {
386
+ try {
387
+ const actorUid = serviceUid(req);
388
+ const targetUid = validId(req.body?.uid);
389
+ const start = validPage(req.body?.start, 0, 10000000);
390
+ const limit = validPage(req.body?.limit, 100, 100);
391
+
392
+ if (!actorUid || !targetUid || start < 0 || limit < 1) {
393
+ return res.status(400).json({ error: "Invalid bulk deletion parameters" });
394
+ }
395
+
396
+ const pids = await Posts.getUserPostIds(targetUid, start, start + limit - 1);
397
+ const results = {
398
+ uid: targetUid,
399
+ scanned: pids.length,
400
+ deleted: 0,
401
+ skipped: 0,
402
+ failed: [],
403
+ hasMore: pids.length === limit
404
+ };
405
+
406
+ for (const rawPid of pids) {
407
+ const pid = validId(rawPid);
408
+
409
+ if (!pid) {
410
+ results.skipped++;
411
+ continue;
412
+ }
413
+
414
+ try {
415
+ const post = await Posts.getPostMeta(pid);
416
+
417
+ if (!post || post.deleted || Number(post.uid) !== targetUid) {
418
+ results.skipped++;
419
+ continue;
420
+ }
421
+
422
+ const topic = await Topics.getTopic(post.tid);
423
+
424
+ if (!topic || Number(topic.mainPid) === pid) {
425
+ results.skipped++;
426
+ continue;
427
+ }
428
+
429
+ await Posts.deletePost(pid, actorUid);
430
+ results.deleted++;
431
+ } catch (error) {
432
+ results.failed.push({
433
+ pid,
434
+ error: error.message || "Deletion failed"
435
+ });
436
+ }
437
+ }
438
+
439
+ results.nextStart = start + pids.length - results.deleted;
440
+
441
+ return res.json(results);
442
+ } catch (error) {
443
+ return operationError(res, error, "Bulk post deletion");
444
+ }
445
+ };
446
+
447
+ exports.internalListPosts = async function (req, res) {
448
+ try {
449
+ const uid = serviceUid(req);
450
+ const tid = validId(req.body?.tid);
451
+ const start = validPage(req.body?.start, 0, 10000000);
452
+ const limit = validPage(req.body?.limit, 20, 100);
453
+ const reverse = req.body?.reverse ?? false;
454
+
455
+ if (!uid || !tid || start < 0 || limit < 1 ||
456
+ typeof reverse !== "boolean") {
457
+ return res.status(400).json({ error: "Invalid post listing parameters" });
458
+ }
459
+
460
+ if (!(await Privileges.canReadTopic(tid, uid))) {
461
+ return res.status(404).json({ error: "Topic not found" });
462
+ }
463
+
464
+ const topic = await Topics.getTopic(tid);
465
+
466
+ if (!topic || topic.deleted) {
467
+ return res.status(404).json({ error: "Topic not found" });
468
+ }
469
+
470
+ const posts = await Topics.getTopicPosts(tid, uid, start, limit, reverse);
471
+
472
+ const total = Math.max(0, Number(topic.postcount) || 0);
473
+ const nextStart = Math.min(start + limit, total);
474
+
475
+ return res.json({
476
+ tid,
477
+ start,
478
+ limit,
479
+ reverse,
480
+ posts,
481
+ total,
482
+ nextStart,
483
+ hasMore: nextStart < total
484
+ });
485
+ } catch (error) {
486
+ return operationError(res, error, "Post listing");
487
+ }
488
+ };
489
+
227
490
  exports.renderAdmin = async function (req, res) {
228
491
  try {
229
492
  const [settings, allCategories] = await Promise.all([
@@ -232,7 +495,6 @@ exports.renderAdmin = async function (req, res) {
232
495
  ]);
233
496
 
234
497
  const excluded = new Set(settings.excludedCategories.map(Number));
235
-
236
498
  const categories = allCategories
237
499
  .filter(category => category.cid > 0)
238
500
  .map(category => ({
@@ -17,44 +17,37 @@ exports.init = function (router, middleware) {
17
17
  throw new Error("[Integration] setupAdminPageRoute unavailable");
18
18
  }
19
19
 
20
- // Existing user-facing semantic search.
21
20
  router.post(
22
21
  "/api/v3/integration/search",
23
22
  middleware.ensureLoggedIn,
24
23
  API.apiSearch
25
24
  );
26
25
 
27
- router.get(
28
- "/api/v3/integration/internal/info",
29
- API.internalAuth,
30
- API.internalInfo
31
- );
32
-
33
- router.post(
34
- "/api/v3/integration/internal/post",
35
- API.internalAuth,
36
- API.internalPost
37
- );
38
-
39
- router.post(
40
- "/api/v3/integration/internal/topic",
41
- API.internalAuth,
42
- API.internalTopic
43
- );
44
-
45
- router.post(
46
- "/api/v3/integration/internal/user",
47
- API.internalAuth,
48
- API.internalUser
49
- );
50
-
51
- router.post(
52
- "/api/v3/integration/internal/search",
53
- API.internalAuth,
54
- API.internalSearch
55
- );
26
+ const internal = "/api/v3/integration/internal";
27
+
28
+ router.get(`${internal}/info`, API.internalAuth, API.internalInfo);
29
+
30
+ const routes = {
31
+ post: API.internalPost,
32
+ topic: API.internalTopic,
33
+ user: API.internalUser,
34
+ search: API.internalSearch,
35
+ "topic/create": API.internalCreateTopic,
36
+ "topic/delete": API.internalDeleteTopic,
37
+ "topic/restore": API.internalRestoreTopic,
38
+ topics: API.internalListTopics,
39
+ "post/create": API.internalCreatePost,
40
+ "post/edit": API.internalEditPost,
41
+ "post/delete": API.internalDeletePost,
42
+ "post/restore": API.internalRestorePost,
43
+ "user/delete-posts": API.internalDeleteUserPosts,
44
+ posts: API.internalListPosts
45
+ };
46
+
47
+ for (const [path, handler] of Object.entries(routes)) {
48
+ router.post(`${internal}/${path}`, API.internalAuth, handler);
49
+ }
56
50
 
57
- // Existing ACP page.
58
51
  helpers.setupAdminPageRoute(
59
52
  router,
60
53
  "/admin/plugins/integration",