nodebb-plugin-integration 0.4.2 → 0.4.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/src/routes/api.js CHANGED
@@ -1,97 +1,255 @@
1
- "use strict";
2
-
3
- /**
4
- * NodeBB Integration Layer
5
- *
6
- * API controllers
7
- */
8
-
9
- const Config = require("../config");
10
- const Search = require("../ai/search");
11
- const Meta = require("../nodebb/meta");
12
-
13
- /**
14
- * GET /api/v3/integration/info
15
- */
16
- exports.apiInfo = async function (req, res) {
17
-
18
- try {
19
-
20
- const forum = await Meta.getInfo();
21
- const settings = await Config.info();
22
-
23
- res.json({
24
- success: true,
25
- forum,
26
- settings
27
- });
28
-
29
- } catch (err) {
30
-
31
- res.status(500).json({
32
- success: false,
33
- error: err.message
34
- });
35
-
36
- }
37
-
38
- };
39
-
40
- /**
41
- * POST /api/v3/integration/search
42
- */
43
- exports.apiSearch = async function (req, res) {
44
-
45
- try {
46
-
47
- const query = req.body?.query || "";
48
- const limit = Number(req.body?.limit || 5);
49
-
50
- const result = await Search.searchText(
51
- query,
52
- limit
53
- );
54
-
55
- res.json({
56
- success: true,
57
- result
58
- });
59
-
60
- } catch (err) {
61
-
62
- res.status(500).json({
63
- success: false,
64
- error: err.message
65
- });
66
-
67
- }
68
-
69
- };
70
-
71
- /**
72
- * ACP page
73
- */
74
- exports.renderAdmin = async function (req, res) {
75
-
76
- try {
77
-
78
- const settings = await Config.get();
79
-
80
- res.render(
81
- "admin/plugins/integration",
82
- {
83
- title: "NodeBB Integration",
84
-
85
- settings
86
- }
87
- );
88
-
89
- } catch (err) {
90
-
91
- res.status(500).render("500", {
92
- error: err.message
93
- });
94
-
95
- }
96
-
97
- };
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+
5
+ const Config = require("../config");
6
+ const Search = require("../ai/search");
7
+ const Categories = require("../nodebb/categories");
8
+ const Meta = require("../nodebb/meta");
9
+ const Posts = require("../nodebb/posts");
10
+ const Topics = require("../nodebb/topics");
11
+ const Users = require("../nodebb/users");
12
+ const Privileges = require("../nodebb/privileges");
13
+
14
+ function validId(value) {
15
+ const id = Number(value);
16
+ return Number.isSafeInteger(id) && id > 0 ? id : 0;
17
+ }
18
+
19
+ function internalTokenMatches(req, expected) {
20
+ const authorization = req.headers.authorization || "";
21
+ const match = authorization.match(/^Bearer\s+([^\s]+)$/i);
22
+
23
+ if (!match || typeof expected !== "string" || !expected) {
24
+ return false;
25
+ }
26
+
27
+ const actualBuffer = Buffer.from(match[1]);
28
+ const expectedBuffer = Buffer.from(expected);
29
+
30
+ return actualBuffer.length === expectedBuffer.length &&
31
+ crypto.timingSafeEqual(actualBuffer, expectedBuffer);
32
+ }
33
+
34
+ exports.internalAuth = async function (req, res, next) {
35
+ try {
36
+ const settings = await Config.get();
37
+
38
+ if (!settings.mcpEnabled) {
39
+ return res.status(403).json({ error: "MCP is disabled" });
40
+ }
41
+
42
+ if (!internalTokenMatches(req, settings.internalApiToken)) {
43
+ return res.status(401).json({ error: "Unauthorized" });
44
+ }
45
+
46
+ req.integrationSettings = settings;
47
+ return next();
48
+ } catch (error) {
49
+ console.error("[Integration] Internal authentication failed:", error.message);
50
+ return res.status(500).json({ error: "Internal API error" });
51
+ }
52
+ };
53
+
54
+ exports.apiSearch = async function (req, res) {
55
+ try {
56
+ const query = typeof req.body?.query === "string"
57
+ ? req.body.query.trim()
58
+ : "";
59
+
60
+ if (!query || query.length > 2000) {
61
+ return res.status(400).json({ error: "Invalid search query" });
62
+ }
63
+
64
+ const results = await Search.searchText(query, req.uid);
65
+
66
+ return res.json({ results });
67
+ } catch (error) {
68
+ if (error.code === "AI_DISABLED") {
69
+ return res.status(503).json({ error: error.message });
70
+ }
71
+
72
+ console.error("[NodeBB Integration] Search failed:", error);
73
+ return res.status(500).json({ error: "Search failed" });
74
+ }
75
+ };
76
+
77
+ exports.internalInfo = async function (req, res) {
78
+ try {
79
+ return res.json(await Meta.getInfo());
80
+ } catch (error) {
81
+ console.error("[Integration] Forum info failed:", error.message);
82
+ return res.status(500).json({ error: "Unable to get forum information" });
83
+ }
84
+ };
85
+
86
+ exports.internalPost = async function (req, res) {
87
+ try {
88
+ const pid = validId(req.body?.pid);
89
+ const uid = req.integrationSettings.mcpServiceUid;
90
+
91
+ if (!pid) {
92
+ return res.status(400).json({ error: "Invalid post ID" });
93
+ }
94
+
95
+ const post = await Posts.getPostMeta(pid);
96
+
97
+ if (!post || post.deleted) {
98
+ return res.status(404).json({ error: "Post not found" });
99
+ }
100
+
101
+ const tid = validId(post.tid);
102
+
103
+ if (!tid) {
104
+ return res.status(404).json({ error: "Post not found" });
105
+ }
106
+
107
+ const [postAllowed, topicAllowed] = await Promise.all([
108
+ Privileges.canReadPost(pid, uid),
109
+ Privileges.canReadTopic(tid, uid)
110
+ ]);
111
+
112
+ if (!postAllowed || !topicAllowed) {
113
+ return res.status(404).json({ error: "Post not found" });
114
+ }
115
+
116
+ const content = await Posts.getPostContent(pid);
117
+
118
+ if (!content) {
119
+ return res.status(404).json({ error: "Post not found" });
120
+ }
121
+
122
+ return res.json({
123
+ pid: Number(post.pid),
124
+ tid,
125
+ uid: Number(post.uid),
126
+ cid: Number(post.cid),
127
+ content: content.content,
128
+ timestamp: post.timestamp
129
+ });
130
+ } catch (error) {
131
+ console.error("[Integration] MCP post retrieval failed:", error.message);
132
+ return res.status(500).json({ error: "Unable to get post" });
133
+ }
134
+ };
135
+
136
+ exports.internalTopic = async function (req, res) {
137
+ try {
138
+ const tid = validId(req.body?.tid);
139
+ const uid = req.integrationSettings.mcpServiceUid;
140
+
141
+ if (!tid) {
142
+ return res.status(400).json({ error: "Invalid topic ID" });
143
+ }
144
+
145
+ if (!(await Privileges.canReadTopic(tid, uid))) {
146
+ return res.status(404).json({ error: "Topic not found" });
147
+ }
148
+
149
+ const topic = await Topics.getTopic(tid);
150
+
151
+ if (!topic || topic.deleted) {
152
+ return res.status(404).json({ error: "Topic not found" });
153
+ }
154
+
155
+ return res.json({
156
+ tid: Number(topic.tid),
157
+ uid: Number(topic.uid),
158
+ title: topic.title,
159
+ cid: Number(topic.cid),
160
+ timestamp: topic.timestamp,
161
+ mainPid: Number(topic.mainPid)
162
+ });
163
+ } catch (error) {
164
+ console.error("[Integration] MCP topic retrieval failed:", error.message);
165
+ return res.status(500).json({ error: "Unable to get topic" });
166
+ }
167
+ };
168
+
169
+ exports.internalUser = async function (req, res) {
170
+ try {
171
+ const uid = validId(req.body?.uid);
172
+
173
+ if (!uid) {
174
+ return res.status(400).json({ error: "Invalid user ID" });
175
+ }
176
+
177
+ const user = await Users.getUser(uid);
178
+
179
+ if (!user || !Number(user.uid)) {
180
+ return res.status(404).json({ error: "User not found" });
181
+ }
182
+
183
+ return res.json({
184
+ uid: Number(user.uid),
185
+ username: user.username,
186
+ userslug: user.userslug,
187
+ picture: user.picture
188
+ });
189
+ } catch (error) {
190
+ console.error("[Integration] MCP user retrieval failed:", error.message);
191
+ return res.status(500).json({ error: "Unable to get user" });
192
+ }
193
+ };
194
+
195
+ exports.internalSearch = async function (req, res) {
196
+ try {
197
+ const query = typeof req.body?.query === "string"
198
+ ? req.body.query.trim()
199
+ : "";
200
+
201
+ const requestedLimit = Number(req.body?.limit ?? 5);
202
+
203
+ if (!query || query.length > 2000) {
204
+ return res.status(400).json({ error: "Invalid search query" });
205
+ }
206
+
207
+ if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) {
208
+ return res.status(400).json({ error: "Invalid search limit" });
209
+ }
210
+
211
+ const limit = Math.min(requestedLimit, 20);
212
+ const uid = req.integrationSettings.mcpServiceUid;
213
+
214
+ const results = await Search.searchText(query, uid, limit);
215
+
216
+ return res.json({ results });
217
+ } catch (error) {
218
+ if (error.code === "AI_DISABLED") {
219
+ return res.status(503).json({ error: error.message });
220
+ }
221
+
222
+ console.error("[Integration] MCP search failed:", error.message);
223
+ return res.status(500).json({ error: "Search failed" });
224
+ }
225
+ };
226
+
227
+ exports.renderAdmin = async function (req, res) {
228
+ try {
229
+ const [settings, allCategories] = await Promise.all([
230
+ Config.info(),
231
+ Categories.getAll()
232
+ ]);
233
+
234
+ const excluded = new Set(settings.excludedCategories.map(Number));
235
+
236
+ const categories = allCategories
237
+ .filter(category => category.cid > 0)
238
+ .map(category => ({
239
+ ...category,
240
+ selected: excluded.has(Number(category.cid))
241
+ }));
242
+
243
+ return res.render("admin/plugins/integration", {
244
+ title: "NodeBB Integration",
245
+ settings,
246
+ categories
247
+ });
248
+ } catch (error) {
249
+ console.error("[NodeBB Integration] ACP render failed:", error);
250
+
251
+ return res.status(500).render("500", {
252
+ title: "NodeBB Integration error"
253
+ });
254
+ }
255
+ };
@@ -1,49 +1,64 @@
1
- "use strict";
2
-
3
- /**
4
- * NodeBB Integration Layer
5
- *
6
- * Routes
7
- */
8
-
9
- const API = require("./api");
10
-
11
- exports.init = function (router, middleware) {
12
-
13
- if (!router) {
14
- return;
15
- }
16
-
17
- /*
18
- * REST API
19
- */
20
-
21
- router.get(
22
- "/api/v3/integration/info",
23
- API.apiInfo
24
- );
25
-
26
- router.post(
27
- "/api/v3/integration/search",
28
- API.apiSearch
29
- );
30
-
31
- /*
32
- * ACP page
33
- */
34
-
35
- if (
36
- middleware &&
37
- middleware.admin &&
38
- typeof middleware.admin.buildHeader === "function"
39
- ) {
40
-
41
- router.get(
42
- "/admin/plugins/integration",
43
- middleware.admin.buildHeader,
44
- API.renderAdmin
45
- );
46
-
47
- }
48
-
49
- };
1
+ "use strict";
2
+
3
+ const API = require("./api");
4
+
5
+ exports.init = function (router, middleware) {
6
+ if (!router || !middleware) {
7
+ throw new Error("[Integration] Router middleware unavailable");
8
+ }
9
+
10
+ if (typeof middleware.pluginHooks !== "function") {
11
+ throw new Error("[Integration] pluginHooks middleware unavailable");
12
+ }
13
+
14
+ const helpers = nodebb.require("./src/routes/helpers");
15
+
16
+ if (typeof helpers.setupAdminPageRoute !== "function") {
17
+ throw new Error("[Integration] setupAdminPageRoute unavailable");
18
+ }
19
+
20
+ // Existing user-facing semantic search.
21
+ router.post(
22
+ "/api/v3/integration/search",
23
+ middleware.ensureLoggedIn,
24
+ API.apiSearch
25
+ );
26
+
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
+ );
56
+
57
+ // Existing ACP page.
58
+ helpers.setupAdminPageRoute(
59
+ router,
60
+ "/admin/plugins/integration",
61
+ [middleware.pluginHooks],
62
+ API.renderAdmin
63
+ );
64
+ };
@@ -1,130 +0,0 @@
1
- <div class="acp-page-container">
2
-
3
- <h1>
4
- <i class="fa fa-robot"></i>
5
- {{title}}
6
- </h1>
7
-
8
- <hr />
9
-
10
- <form role="form">
11
-
12
- <div class="mb-3">
13
-
14
- <label class="form-label">
15
- {{settings.ai-provider}}
16
- </label>
17
-
18
- <select
19
- class="form-select"
20
- name="aiProvider"
21
- >
22
- <option value="fake">Fake</option>
23
- <option value="openai">OpenAI</option>
24
- </select>
25
-
26
- </div>
27
-
28
- <div class="mb-3">
29
-
30
- <label class="form-label">
31
- OpenAI API Key
32
- </label>
33
-
34
- <input
35
- type="password"
36
- class="form-control"
37
- name="openaiApiKey"
38
- autocomplete="off"
39
- />
40
-
41
- </div>
42
-
43
- <div class="mb-3">
44
-
45
- <label class="form-label">
46
- OpenAI Embedding Model
47
- </label>
48
-
49
- <input
50
- class="form-control"
51
- name="openaiModel"
52
- />
53
-
54
- </div>
55
-
56
- <div class="mb-3">
57
-
58
- <label class="form-label">
59
- Qdrant URL
60
- </label>
61
-
62
- <input
63
- class="form-control"
64
- name="qdrantUrl"
65
- />
66
-
67
- </div>
68
-
69
- <div class="mb-3">
70
-
71
- <label class="form-label">
72
- Collection
73
- </label>
74
-
75
- <input
76
- class="form-control"
77
- name="collection"
78
- />
79
-
80
- </div>
81
-
82
- <div class="form-check mb-3">
83
-
84
- <input
85
- id="mcpEnabled"
86
- class="form-check-input"
87
- type="checkbox"
88
- name="mcpEnabled"
89
- />
90
-
91
- <label
92
- class="form-check-label"
93
- for="mcpEnabled"
94
- >
95
- Enable MCP Server
96
- </label>
97
-
98
- </div>
99
-
100
- <div class="mb-3">
101
-
102
- <label class="form-label">
103
- MCP Port
104
- </label>
105
-
106
- <input
107
- class="form-control"
108
- name="mcpPort"
109
- type="number"
110
- />
111
-
112
- </div>
113
-
114
- <hr />
115
-
116
- <button
117
- id="save"
118
- class="btn btn-primary"
119
- type="button"
120
- >
121
-
122
- <i class="fa fa-save"></i>
123
-
124
- Save Settings
125
-
126
- </button>
127
-
128
- </form>
129
-
130
- </div>