@jasonwch/nodebb-plugin-meilisearch-r 1.1.2 → 1.2.1
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 +29 -28
- package/lib/alerts.js +24 -0
- package/lib/chat-search-global.js +221 -0
- package/lib/client.js +497 -0
- package/lib/constants.js +42 -0
- package/lib/defaults.js +73 -0
- package/lib/embedder.js +297 -0
- package/lib/hooks/messages.js +49 -0
- package/lib/hooks/posts.js +186 -0
- package/lib/hooks/topics.js +218 -0
- package/lib/pending-queue.js +69 -0
- package/lib/redact.js +131 -0
- package/lib/reindex.js +311 -0
- package/lib/routes.js +129 -0
- package/lib/search.js +249 -0
- package/lib/settings-helpers.js +15 -0
- package/lib/settings.js +136 -0
- package/lib/state.js +69 -0
- package/library.js +17 -1555
- package/package.json +1 -1
- package/static/languages/en-GB/meilisearch.json +112 -79
- package/static/languages/he/meilisearch.json +113 -0
- package/static/languages/pl/meilisearch.json +34 -1
- package/static/languages/zh-CN/meilisearch.json +34 -1
- package/static/lib/admin.js +317 -2
- package/static/templates/admin/plugins/meilisearch.tpl +289 -222
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const winston = nodebb.require('winston');
|
|
4
|
+
const Topics = nodebb.require('./src/topics');
|
|
5
|
+
const batch = nodebb.require('./src/batch');
|
|
6
|
+
const { REINDEX_BATCH_SIZE } = require('../constants');
|
|
7
|
+
const { maybeDefer, meiliWrite, enqueuePending } = require('../pending-queue');
|
|
8
|
+
|
|
9
|
+
// action:topic.* hooks (post/save/edit/move/restore/delete/merge/changeOwner/purge)
|
|
10
|
+
// plus scheduled-topic publish handling.
|
|
11
|
+
module.exports = function attachTopicHooks(plugin) {
|
|
12
|
+
plugin.indexTopic = async function ({ topic, tid, fromCid, post }) {
|
|
13
|
+
const isMove = fromCid !== undefined;
|
|
14
|
+
if (await maybeDefer(plugin, 'indexTopic', { topic, tid, fromCid, post })) return;
|
|
15
|
+
// #2: On topic.move, no 'topic' key is provided — re-fetch by tid.
|
|
16
|
+
if (!topic && tid) {
|
|
17
|
+
topic = await Topics.getTopicData(tid);
|
|
18
|
+
}
|
|
19
|
+
if (!topic) {
|
|
20
|
+
winston.warn('[plugin/meilisearch] indexTopic: no topic data');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
// Issue 3: Skip deleted topics (admin editing/moving a deleted topic should not re-index it).
|
|
24
|
+
if (parseInt(topic.deleted, 10) === 1) {
|
|
25
|
+
winston.debug(`[plugin/meilisearch] Skipping deleted topic ${topic.tid}`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// Issue 5: Skip scheduled topics (timestamp in future) — indexed on publish via onScheduledPublish.
|
|
29
|
+
// action:topic.post fires AFTER action:post.save, so the main post may already be in Meili.
|
|
30
|
+
// Deindex it here to prevent full-search from finding scheduled content prematurely.
|
|
31
|
+
if (topic.scheduled || (topic.timestamp && topic.timestamp > Date.now())) {
|
|
32
|
+
const mainPid = post ? post.pid : (parseInt(topic.mainPid, 10) > 0 ? topic.mainPid : null);
|
|
33
|
+
if (mainPid) {
|
|
34
|
+
await plugin.client.index('post').deleteDocument(mainPid).catch(() => {});
|
|
35
|
+
}
|
|
36
|
+
winston.debug(`[plugin/meilisearch] Skipping scheduled topic ${topic.tid}, deindexed main post ${mainPid || 'N/A'}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
await meiliWrite(plugin, 'indexTopic', { topic, tid, fromCid, post }, () =>
|
|
40
|
+
plugin.client.index('topic').updateDocuments([{
|
|
41
|
+
tid: topic.tid,
|
|
42
|
+
cid: topic.cid,
|
|
43
|
+
uid: topic.uid,
|
|
44
|
+
mainPid: topic.mainPid,
|
|
45
|
+
title: topic.title,
|
|
46
|
+
timestamp: topic.timestamp,
|
|
47
|
+
}], { primaryKey: 'tid' }));
|
|
48
|
+
// #4: On topic move, re-index child posts' cids (fire-and-forget with fallback).
|
|
49
|
+
if (isMove && topic.tid) {
|
|
50
|
+
if (plugin.healthy) {
|
|
51
|
+
plugin.reindexTopicPosts({ tid: topic.tid }).catch(err =>
|
|
52
|
+
winston.error(`[plugin/meilisearch] reindexTopicPosts failed: ${err.message}`));
|
|
53
|
+
} else {
|
|
54
|
+
await enqueuePending('reindexTopicPosts', { tid: topic.tid });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
plugin.deindexTopic = async function ({ topic, tid }) {
|
|
60
|
+
if (await maybeDefer(plugin, 'deindexTopic', { topic, tid })) return;
|
|
61
|
+
// Tolerate topic.move-style payloads (no 'topic' key) — extract tid.
|
|
62
|
+
const topicTid = topic ? topic.tid : tid;
|
|
63
|
+
if (!topicTid) {
|
|
64
|
+
winston.warn('[plugin/meilisearch] deindexTopic: no tid');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await meiliWrite(plugin, 'deindexTopic', { topic, tid }, () =>
|
|
68
|
+
plugin.client.index('topic').deleteDocument(topicTid));
|
|
69
|
+
// Also deindex the topic's posts from the post index so admin /search
|
|
70
|
+
// (which queries both indexes and bypasses NodeBB's relevance-sort
|
|
71
|
+
// deleted-content filter for admins) can't surface deleted-topic content.
|
|
72
|
+
if (plugin.healthy) {
|
|
73
|
+
plugin.deindexTopicPosts({ tid: topicTid }).catch(err =>
|
|
74
|
+
winston.error(`[plugin/meilisearch] deindexTopicPosts failed: ${err.message}`));
|
|
75
|
+
} else {
|
|
76
|
+
await enqueuePending('deindexTopicPosts', { tid: topicTid });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
plugin.restoreTopic = async function ({ topic, tid }) {
|
|
81
|
+
const topicTid = topic ? topic.tid : tid;
|
|
82
|
+
if (await maybeDefer(plugin, 'restoreTopic', { topic, tid })) return;
|
|
83
|
+
// Re-fetch topic if missing (defensive — same pattern as indexTopic).
|
|
84
|
+
if (!topic && topicTid) {
|
|
85
|
+
topic = await Topics.getTopicData(topicTid);
|
|
86
|
+
}
|
|
87
|
+
if (!topic) {
|
|
88
|
+
winston.warn('[plugin/meilisearch] restoreTopic: no topic data');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Re-index the topic document.
|
|
92
|
+
await meiliWrite(plugin, 'restoreTopic', { topic, tid }, () =>
|
|
93
|
+
plugin.client.index('topic').updateDocuments([{
|
|
94
|
+
tid: topic.tid,
|
|
95
|
+
cid: topic.cid,
|
|
96
|
+
uid: topic.uid,
|
|
97
|
+
mainPid: topic.mainPid,
|
|
98
|
+
title: topic.title,
|
|
99
|
+
timestamp: topic.timestamp,
|
|
100
|
+
}], { primaryKey: 'tid' }));
|
|
101
|
+
// Re-index the topic's posts (they were deindexed on delete).
|
|
102
|
+
if (plugin.healthy) {
|
|
103
|
+
plugin.reindexTopicPosts({ tid: topic.tid }).catch(err =>
|
|
104
|
+
winston.error(`[plugin/meilisearch] restoreTopic reindexTopicPosts failed: ${err.message}`));
|
|
105
|
+
} else {
|
|
106
|
+
await enqueuePending('reindexTopicPosts', { tid: topic.tid });
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
plugin.changeTopicOwner = async function ({ topics, toUid }) {
|
|
111
|
+
if (await maybeDefer(plugin, 'changeTopicOwner', { topics, toUid })) return;
|
|
112
|
+
// topics have OLD uid — re-fetch from DB to get fresh uid.
|
|
113
|
+
// Skip deleted or scheduled topics.
|
|
114
|
+
const validTids = (Array.isArray(topics) ? topics : [])
|
|
115
|
+
.filter(t => t && t.tid && parseInt(t.deleted, 10) !== 1)
|
|
116
|
+
.map(t => t.tid);
|
|
117
|
+
if (!validTids.length) return;
|
|
118
|
+
const freshTopics = await Topics.getTopicsFields(validTids, ['tid', 'cid', 'uid', 'mainPid', 'title', 'timestamp']);
|
|
119
|
+
// Fix 7: Skip scheduled topics (timestamp in future).
|
|
120
|
+
const activeTopics = freshTopics.filter(t => t && !(t.timestamp && t.timestamp > Date.now()));
|
|
121
|
+
if (!activeTopics.length) return;
|
|
122
|
+
const docs = activeTopics.map(topic => ({
|
|
123
|
+
tid: topic.tid,
|
|
124
|
+
cid: topic.cid,
|
|
125
|
+
uid: topic.uid,
|
|
126
|
+
mainPid: topic.mainPid,
|
|
127
|
+
title: topic.title,
|
|
128
|
+
timestamp: topic.timestamp,
|
|
129
|
+
}));
|
|
130
|
+
await meiliWrite(plugin, 'changeTopicOwner', { topics: activeTopics.map(t => ({ tid: t.tid })), toUid }, () =>
|
|
131
|
+
plugin.client.index('topic').updateDocuments(docs, { primaryKey: 'tid' }));
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
plugin.onTopicMerge = async function ({ otherTids, mergeIntoTid }) {
|
|
135
|
+
if (await maybeDefer(plugin, 'onTopicMerge', { otherTids, mergeIntoTid })) return;
|
|
136
|
+
const postIndex = plugin.client.index('post');
|
|
137
|
+
// Deindex merged-out topics + their posts (Topics.delete in merge fires no action:topic.delete).
|
|
138
|
+
for (const tid of otherTids) {
|
|
139
|
+
await plugin.deindexTopic({ tid });
|
|
140
|
+
// Merge zeros mainPid before Topics.delete, so deindexTopicPosts can't find the main post.
|
|
141
|
+
// Fallback: search Meili for any orphaned posts still carrying this tid.
|
|
142
|
+
try {
|
|
143
|
+
const result = await postIndex.search('', { filter: `tid = ${parseInt(tid, 10)}`, limit: 1000 });
|
|
144
|
+
if (result.hits && result.hits.length) {
|
|
145
|
+
const orphanPids = result.hits.map(h => h.pid);
|
|
146
|
+
await meiliWrite(plugin, 'deindexPostsPurge', { posts: orphanPids.map(pid => ({ pid })) }, () =>
|
|
147
|
+
postIndex.deleteDocuments(orphanPids));
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// B2b: SDK write error path — may contain document content snippets from
|
|
151
|
+
// already-indexed posts (rare; only if Meilisearch rejects a batch quoting
|
|
152
|
+
// the bad doc). Accepted risk for low-frequency write failures; document
|
|
153
|
+
// content is already-in-DB data.
|
|
154
|
+
winston.error(`[plugin/meilisearch] onTopicMerge orphan cleanup for tid ${tid} failed: ${err.message}`);
|
|
155
|
+
plugin.healthy = false;
|
|
156
|
+
// Don't enqueue — orphans are cleaned by next reindex. Continue to next tid.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Re-index the merge target (postcount/lastpost changed; posts already moved via action:post.move).
|
|
160
|
+
await plugin.indexTopic({ tid: mergeIntoTid });
|
|
161
|
+
if (plugin.healthy) {
|
|
162
|
+
plugin.reindexTopicPosts({ tid: mergeIntoTid }).catch(err =>
|
|
163
|
+
winston.error(`[plugin/meilisearch] onTopicMerge reindexTopicPosts failed: ${err.message}`));
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
plugin.onScheduledPublish = async function ({ topics }) {
|
|
168
|
+
const tids = (Array.isArray(topics) ? topics : []).filter(t => t && t.tid).map(t => t.tid);
|
|
169
|
+
if (!tids.length) return;
|
|
170
|
+
if (await maybeDefer(plugin, 'onScheduledPublish', { topics: tids.map(tid => ({ tid })) })) return;
|
|
171
|
+
// Re-index each published topic + its posts (DB deleted=0, timestamp in past now).
|
|
172
|
+
for (const tid of tids) {
|
|
173
|
+
await plugin.indexTopic({ tid });
|
|
174
|
+
if (plugin.healthy) {
|
|
175
|
+
plugin.reindexTopicPosts({ tid }).catch(err =>
|
|
176
|
+
winston.error(`[plugin/meilisearch] onScheduledPublish reindexTopicPosts failed: ${err.message}`));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
plugin.deindexTopicsPurge = async function ({ topics }) {
|
|
182
|
+
if (await maybeDefer(plugin, 'deindexTopicsPurge', { topics })) return;
|
|
183
|
+
const topicsArr = Array.isArray(topics) ? topics.filter(t => t && t.tid) : [];
|
|
184
|
+
if (!topicsArr.length) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const tids = topicsArr.map(t => t.tid);
|
|
188
|
+
const mainPids = topicsArr.map(t => t.mainPid).filter(Boolean);
|
|
189
|
+
const postIndex = plugin.client.index('post');
|
|
190
|
+
const reducedPayload = { topics: topicsArr.map(t => ({ tid: t.tid, mainPid: t.mainPid })) };
|
|
191
|
+
try {
|
|
192
|
+
await Promise.all([
|
|
193
|
+
plugin.client.index('topic').deleteDocuments(tids),
|
|
194
|
+
mainPids.length ? postIndex.deleteDocuments(mainPids) : Promise.resolve(),
|
|
195
|
+
]);
|
|
196
|
+
await Promise.all(topicsArr.map(async (t) => {
|
|
197
|
+
await batch.processSortedSet(
|
|
198
|
+
`tid:${t.tid}:posts`,
|
|
199
|
+
async (pids) => {
|
|
200
|
+
if (!pids.length) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
await postIndex.deleteDocuments(pids);
|
|
204
|
+
},
|
|
205
|
+
{ batch: REINDEX_BATCH_SIZE },
|
|
206
|
+
);
|
|
207
|
+
}));
|
|
208
|
+
} catch (err) {
|
|
209
|
+
// B2b: SDK write error path — may contain document content snippets from
|
|
210
|
+
// already-indexed posts (rare; only if Meilisearch rejects a batch quoting
|
|
211
|
+
// the bad doc). Accepted risk for low-frequency write failures; document
|
|
212
|
+
// content is already-in-DB data.
|
|
213
|
+
winston.error(`[plugin/meilisearch] deindexTopicsPurge failed: ${err.message}`);
|
|
214
|
+
plugin.healthy = false;
|
|
215
|
+
await enqueuePending('deindexTopicsPurge', reducedPayload);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const winston = nodebb.require('winston');
|
|
4
|
+
const db = nodebb.require('./src/database');
|
|
5
|
+
const { PENDING_KEY, PENDING_MAX, DRAIN_MAX, REPLAY_OPS } = require('./constants');
|
|
6
|
+
|
|
7
|
+
// Persists a write that couldn't be applied to MeiliSearch yet, so it can be replayed later.
|
|
8
|
+
async function enqueuePending(op, payload) {
|
|
9
|
+
const count = await db.sortedSetCard(PENDING_KEY);
|
|
10
|
+
if (count >= PENDING_MAX) {
|
|
11
|
+
const oldest = await db.getSortedSetRange(PENDING_KEY, 0, 0);
|
|
12
|
+
if (oldest.length) await db.sortedSetRemove(PENDING_KEY, ...oldest);
|
|
13
|
+
}
|
|
14
|
+
await db.sortedSetAdd(PENDING_KEY, Date.now(), JSON.stringify({ op, payload }));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Decides whether a write hook should be deferred instead of applied immediately:
|
|
18
|
+
// buffered in memory during a forced reindex, or queued to the DB while MeiliSearch is unhealthy.
|
|
19
|
+
async function maybeDefer(plugin, op, payload) {
|
|
20
|
+
if (plugin.reindexingForced) {
|
|
21
|
+
if (plugin.pendingDuringReindex.length >= PENDING_MAX) {
|
|
22
|
+
plugin.pendingDuringReindex.shift();
|
|
23
|
+
}
|
|
24
|
+
plugin.pendingDuringReindex.push({ op, payload });
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (!plugin.healthy && !(await plugin.checkHealth())) {
|
|
28
|
+
await enqueuePending(op, payload);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Runs a MeiliSearch write, falling back to the pending queue on failure.
|
|
35
|
+
async function meiliWrite(plugin, op, payload, fn) {
|
|
36
|
+
try {
|
|
37
|
+
await fn();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
winston.error(`[plugin/meilisearch] ${op} write failed: ${err.message}`);
|
|
40
|
+
plugin.healthy = false;
|
|
41
|
+
await enqueuePending(op, payload);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Replays queued writes once MeiliSearch is healthy again.
|
|
46
|
+
async function drainPending(plugin) {
|
|
47
|
+
if (!plugin.healthy || plugin.reindexingForced) return;
|
|
48
|
+
let drained = 0;
|
|
49
|
+
for (;;) {
|
|
50
|
+
if (!plugin.healthy) break;
|
|
51
|
+
const members = await db.getSortedSetRange(PENDING_KEY, 0, 49);
|
|
52
|
+
if (!members.length) break;
|
|
53
|
+
await db.sortedSetRemove(PENDING_KEY, ...members);
|
|
54
|
+
for (const member of members) {
|
|
55
|
+
try {
|
|
56
|
+
const { op, payload } = JSON.parse(member);
|
|
57
|
+
if (op && REPLAY_OPS.includes(op)) {
|
|
58
|
+
await plugin[op](payload);
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
winston.error(`[plugin/meilisearch] replay failed: ${err.message}`);
|
|
62
|
+
}
|
|
63
|
+
drained += 1;
|
|
64
|
+
}
|
|
65
|
+
if (drained >= DRAIN_MAX) break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { enqueuePending, maybeDefer, meiliWrite, drainPending };
|
package/lib/redact.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Credential redaction for log/alert paths.
|
|
4
|
+
//
|
|
5
|
+
// Background: M5 + C1 fix. Meilisearch task error messages and settings diff logs may
|
|
6
|
+
// echo the configured apiKey (e.g. "Unauthorized: key sk-abc123" or nested in JSON).
|
|
7
|
+
// Without redaction, these flow to:
|
|
8
|
+
// - winston transports (stdout, logs/output.log, /admin/developer/logs)
|
|
9
|
+
// - log aggregators (Loki/ELK/Datadog) which sit OUTSIDE the admin trust boundary
|
|
10
|
+
// - admin ACP toasts (15s transient — already mitigated by Issue 2 admin-room filter)
|
|
11
|
+
//
|
|
12
|
+
// Strategy: dual-layer redaction
|
|
13
|
+
// 1. Pattern-based regex: catches known API key shapes (OpenAI sk-*, Bearer tokens)
|
|
14
|
+
// even when we can't read the configured value (e.g. message logged before init)
|
|
15
|
+
// 2. Value-based substitution: reads semanticSearchApiKey from settings and string-
|
|
16
|
+
// replaces any literal occurrence — catches custom REST tokens, future OpenAI
|
|
17
|
+
// key formats, and exotic auth schemes that don't match the regex.
|
|
18
|
+
//
|
|
19
|
+
// Value-based redaction requires a settings.getOne DB read on first call; cached for
|
|
20
|
+
// 5 min so throttled error paths (notifyAdmins throttles 60s/key) incur ≤1 DB read/min.
|
|
21
|
+
|
|
22
|
+
const settings = nodebb.require('./src/meta/settings');
|
|
23
|
+
|
|
24
|
+
// Fields whose VALUES must never appear in winston diff logs (lib/settings.js isBreaking).
|
|
25
|
+
// Listed by ACP field name. semanticSearchUrl is intentionally NOT included — it's an
|
|
26
|
+
// internal hostname, not a credential, and admins need it for debugging connectivity.
|
|
27
|
+
const SECRET_SETTINGS = new Set([
|
|
28
|
+
'semanticSearchApiKey',
|
|
29
|
+
'semanticSearchRestRequest',
|
|
30
|
+
'semanticSearchRestResponse',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const REDACTION_PLACEHOLDER = '<redacted>';
|
|
34
|
+
|
|
35
|
+
// Patterns to catch common API key formats in free-form error strings.
|
|
36
|
+
// OpenAI keys: sk-*, sk-proj-*, sk-org-*, sk-svcacct-* — all start with "sk-"
|
|
37
|
+
// and are at least 20 chars long (real keys are 40+ but we redact aggressively at 20).
|
|
38
|
+
const KEY_PATTERNS = [
|
|
39
|
+
/\bsk-[a-zA-Z0-9_-]{20,}\b/g,
|
|
40
|
+
/\bBearer\s+[a-zA-Z0-9_-]{8,}\b/gi,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Min length for value-based redaction — avoids replacing short strings that might
|
|
44
|
+
// legitimately appear in error messages (e.g. an apiKey of "ab" would corrupt many
|
|
45
|
+
// error strings). 8 chars is safely above any plausible short substring.
|
|
46
|
+
const MIN_VALUE_REDACT_LENGTH = 8;
|
|
47
|
+
|
|
48
|
+
// Cached configured semanticSearchApiKey to avoid hitting DB on every redact call.
|
|
49
|
+
// TTL ensures we pick up rotations within 5 min. Cached as the raw string value
|
|
50
|
+
// (or '' when no key configured) so we can distinguish "cached empty" from "not cached".
|
|
51
|
+
let _cachedApiKey = null;
|
|
52
|
+
let _cachedAt = 0;
|
|
53
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
54
|
+
|
|
55
|
+
async function getCachedApiKey(plugin) {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
if (_cachedApiKey !== null && now - _cachedAt < CACHE_TTL_MS) {
|
|
58
|
+
return _cachedApiKey;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
// A1 fix: trim the cached value to match the on-wire key (embedder.js get() trims
|
|
62
|
+
// before sending to Meilisearch, so any echo in errors is the trimmed form).
|
|
63
|
+
// Without this, a padded stored key (" sk-abc ") would defeat value-based redaction
|
|
64
|
+
// when Meilisearch echoes the trimmed key ("sk-abc") in a 401 error.
|
|
65
|
+
_cachedApiKey = ((await settings.getOne(plugin.id, 'semanticSearchApiKey')) || '').trim();
|
|
66
|
+
_cachedAt = now;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// A2 fix: don't set _cachedAt on the failure path — let the next call retry immediately
|
|
69
|
+
// instead of cycling through the full 5-min TTL with a stale empty cache. Pattern-based
|
|
70
|
+
// redaction still runs, so sk-* and Bearer-shaped keys stay protected during the blip.
|
|
71
|
+
_cachedApiKey = '';
|
|
72
|
+
}
|
|
73
|
+
return _cachedApiKey;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Test-only hooks (exported for the test sandbox; not consumed by production code paths).
|
|
77
|
+
function _resetCache() {
|
|
78
|
+
_cachedApiKey = null;
|
|
79
|
+
_cachedAt = 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _setCachedApiKey(value) {
|
|
83
|
+
_cachedApiKey = (value || '').trim();
|
|
84
|
+
_cachedAt = Date.now();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Redact known secret patterns + the configured apiKey value from a free-form string.
|
|
88
|
+
// Used by winston log calls and notifyAdmins payloads so credentials don't leak
|
|
89
|
+
// when Meilisearch echoes them in error messages.
|
|
90
|
+
//
|
|
91
|
+
// Returns the redacted string. If `plugin` is omitted, only pattern-based redaction
|
|
92
|
+
// runs (used in early-init paths before plugin object is fully wired).
|
|
93
|
+
async function redactSecrets(message, plugin) {
|
|
94
|
+
if (typeof message !== 'string' || !message) return message;
|
|
95
|
+
let redacted = message;
|
|
96
|
+
|
|
97
|
+
// Layer 1: pattern-based — catches known formats even without DB access
|
|
98
|
+
for (const pattern of KEY_PATTERNS) {
|
|
99
|
+
redacted = redacted.replace(pattern, REDACTION_PLACEHOLDER);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Layer 2: value-based — catches the exact configured apiKey regardless of format
|
|
103
|
+
if (plugin) {
|
|
104
|
+
const configuredKey = await getCachedApiKey(plugin);
|
|
105
|
+
if (configuredKey && configuredKey.length >= MIN_VALUE_REDACT_LENGTH) {
|
|
106
|
+
// Escape regex special chars in the key (keys can contain +, /, = for base64)
|
|
107
|
+
const escapedKey = configuredKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
108
|
+
redacted = redacted.replace(new RegExp(escapedKey, 'g'), REDACTION_PLACEHOLDER);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return redacted;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Whole-value redaction for winston diff logs (lib/settings.js isBreaking).
|
|
116
|
+
// Returns '<redacted>' for credential-bearing fields, JSON.stringify for others.
|
|
117
|
+
// Preserves the "did it change?" audit signal without leaking the credential value.
|
|
118
|
+
function redactSettingForLog(setting, value) {
|
|
119
|
+
if (SECRET_SETTINGS.has(setting)) return REDACTION_PLACEHOLDER;
|
|
120
|
+
return JSON.stringify(value);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
redactSecrets,
|
|
125
|
+
redactSettingForLog,
|
|
126
|
+
SECRET_SETTINGS,
|
|
127
|
+
REDACTION_PLACEHOLDER,
|
|
128
|
+
// Test-only exports (not consumed by production code paths):
|
|
129
|
+
_resetCache,
|
|
130
|
+
_setCachedApiKey,
|
|
131
|
+
};
|