@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,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const settings = nodebb.require('./src/meta/settings');
|
|
4
|
+
|
|
5
|
+
async function resolveMinTermLength(plugin) {
|
|
6
|
+
const v = parseInt(await settings.getOne(plugin.id, 'searchMinTermLength'), 10);
|
|
7
|
+
return Number.isFinite(v) && v >= 2 ? v : 2;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function clampMinTermLength(value) {
|
|
11
|
+
const v = parseInt(value, 10);
|
|
12
|
+
return Number.isFinite(v) && v >= 2 ? v : 2;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { resolveMinTermLength, clampMinTermLength };
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const winston = nodebb.require('winston');
|
|
4
|
+
const settings = nodebb.require('./src/meta/settings');
|
|
5
|
+
const { clampMinTermLength } = require('./settings-helpers');
|
|
6
|
+
const { SEMANTIC_SETTING_KEYS, resetAppliedConfigs } = require('./embedder');
|
|
7
|
+
const { redactSettingForLog, redactSecrets } = require('./redact');
|
|
8
|
+
|
|
9
|
+
function deepCompare(a, b) {
|
|
10
|
+
if (a === null || b === null) return a === b;
|
|
11
|
+
if (typeof a !== typeof b) return false;
|
|
12
|
+
switch (typeof a) {
|
|
13
|
+
case 'object':
|
|
14
|
+
return Object.keys(a).length === Object.keys(b).length &&
|
|
15
|
+
Object.keys(a).every(key => deepCompare(a[key], b[key]));
|
|
16
|
+
case 'string':
|
|
17
|
+
case 'number':
|
|
18
|
+
case 'boolean':
|
|
19
|
+
default:
|
|
20
|
+
return a === b;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// filter:settings.set — reacts to ACP settings saves: reconnects on connection-setting
|
|
25
|
+
// changes and pushes breaking settings (ranking rules, typo tolerance, etc.) to MeiliSearch.
|
|
26
|
+
module.exports = function attachSettings(plugin) {
|
|
27
|
+
async function connectionSettingsChanged(newSettings) {
|
|
28
|
+
if (!newSettings) return false;
|
|
29
|
+
const keys = ['host', 'apiKey', 'healthCheckInterval'];
|
|
30
|
+
for (const k of keys) {
|
|
31
|
+
if (!Object.prototype.hasOwnProperty.call(newSettings, k)) continue;
|
|
32
|
+
const stored = await settings.getOne(plugin.id, k);
|
|
33
|
+
if (String(stored || '') !== String(newSettings[k] || '')) return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function hostSettingChanged(newSettings) {
|
|
39
|
+
if (!newSettings || !Object.prototype.hasOwnProperty.call(newSettings, 'host')) return false;
|
|
40
|
+
const stored = await settings.getOne(plugin.id, 'host');
|
|
41
|
+
return String(stored || '') !== String(newSettings.host || '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function isBreaking([setting, value]) {
|
|
45
|
+
if (!plugin.breakingSettings.includes(setting)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const stored = await settings.getOne(plugin.id, setting);
|
|
49
|
+
const changed = !deepCompare(stored, value);
|
|
50
|
+
if (changed) {
|
|
51
|
+
// M5 fix: redact credential-bearing fields (apiKey, REST templates) from winston diff log
|
|
52
|
+
// so admin key rotation doesn't leak old+new paid OpenAI keys to output.log/aggregators
|
|
53
|
+
// via the ACP Logs page and any streaming log aggregator (Loki/ELK/Datadog).
|
|
54
|
+
winston.info(`[plugin/meilisearch] ${setting} changed: ${redactSettingForLog(setting, stored)} -> ${redactSettingForLog(setting, value)}`);
|
|
55
|
+
}
|
|
56
|
+
return changed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Deliberately separate from isBreaking()/updateIndexSettings: embedder config
|
|
60
|
+
// changes are the only thing that can make Meilisearch re-embed every document
|
|
61
|
+
// through a (often paid) embedding API, so this must only fire when a semantic
|
|
62
|
+
// search field was itself part of the diff - never as a side effect of saving
|
|
63
|
+
// unrelated settings like ranking rules or stop words.
|
|
64
|
+
async function semanticSettingsChanged(newSettings) {
|
|
65
|
+
if (!newSettings) return false;
|
|
66
|
+
const results = await Promise.all(SEMANTIC_SETTING_KEYS.map(async (key) => {
|
|
67
|
+
if (!Object.prototype.hasOwnProperty.call(newSettings, key)) return false;
|
|
68
|
+
const stored = await settings.getOne(plugin.id, key);
|
|
69
|
+
return !deepCompare(stored, newSettings[key]);
|
|
70
|
+
}));
|
|
71
|
+
return results.some(Boolean);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
plugin.saveSettings = async (data) => {
|
|
75
|
+
if (data.plugin === plugin.id && !data.quiet && plugin.initialized) {
|
|
76
|
+
try {
|
|
77
|
+
if (data.settings && Object.prototype.hasOwnProperty.call(data.settings, 'searchMinTermLength')) {
|
|
78
|
+
data.settings.searchMinTermLength = clampMinTermLength(data.settings.searchMinTermLength);
|
|
79
|
+
}
|
|
80
|
+
// #8: Only re-connect when connection settings changed.
|
|
81
|
+
const connChanged = await connectionSettingsChanged(data.settings);
|
|
82
|
+
if (connChanged) {
|
|
83
|
+
// Fix 10: If host changed, clear indexed so prepareSearch auto-reindexes the new (empty) host.
|
|
84
|
+
const hostChanged = await hostSettingChanged(data.settings);
|
|
85
|
+
if (hostChanged) {
|
|
86
|
+
await settings.set(plugin.id, { indexed: false }, true);
|
|
87
|
+
// The new host has no memory of what embedder config this plugin last
|
|
88
|
+
// applied to the OLD host - without this, an unchanged semantic search
|
|
89
|
+
// setting would look "already applied" and never get pushed to the new host.
|
|
90
|
+
await resetAppliedConfigs(plugin);
|
|
91
|
+
}
|
|
92
|
+
await plugin.prepareSearch(data.settings, true);
|
|
93
|
+
}
|
|
94
|
+
const changed = (await Promise.all(
|
|
95
|
+
Object.entries(data.settings).map(([k, v]) => isBreaking([k, v])),
|
|
96
|
+
)).some(Boolean);
|
|
97
|
+
if (changed) {
|
|
98
|
+
winston.info('settings changed, updating index');
|
|
99
|
+
await plugin.updateIndexSettings(data.settings);
|
|
100
|
+
}
|
|
101
|
+
if (await semanticSettingsChanged(data.settings)) {
|
|
102
|
+
winston.info('[plugin/meilisearch] semantic search settings changed, updating embedders');
|
|
103
|
+
await plugin.updateEmbedders(data.settings);
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
// #7: Don't throw — let settings persist to DB even when Meili is unreachable.
|
|
107
|
+
// C1 fix: redact any apiKey that Meilisearch may echo in its error message before
|
|
108
|
+
// logging. Falls back to pattern-only redaction if settings DB is unreachable.
|
|
109
|
+
// A3-style inner try/catch: fall back to raw err.message if redaction itself fails,
|
|
110
|
+
// so the error still surfaces (just unredacted) rather than silently swallowing it.
|
|
111
|
+
const rawReason = err.message;
|
|
112
|
+
let safeReason;
|
|
113
|
+
try {
|
|
114
|
+
safeReason = await redactSecrets(rawReason, plugin);
|
|
115
|
+
} catch {
|
|
116
|
+
safeReason = rawReason;
|
|
117
|
+
}
|
|
118
|
+
winston.error(`[plugin/meilisearch] Error while saving settings: ${safeReason}`);
|
|
119
|
+
plugin.healthy = false;
|
|
120
|
+
// Surface the failure to admins in the ACP — without this, the client sees the
|
|
121
|
+
// "Settings Saved" success toast even when Meilisearch silently rejected the
|
|
122
|
+
// settings/embedder update. Client-side validation (admin.js) is the primary
|
|
123
|
+
// UX path for invalid configs; this catches connection-level failures (e.g.
|
|
124
|
+
// Meilisearch host unreachable after Fix B's 30s HTTP timeout fires).
|
|
125
|
+
// C1 fix: surface the failure to admins without leaking credentials. The error
|
|
126
|
+
// message may contain the apiKey if Meilisearch echoes it in 401 responses.
|
|
127
|
+
plugin.notifyAdmins('settings:save', {
|
|
128
|
+
type: 'danger',
|
|
129
|
+
titleKey: '[[meilisearch:admin.meilisearchError]]',
|
|
130
|
+
message: safeReason,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return data;
|
|
135
|
+
};
|
|
136
|
+
};
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const winston = nodebb.require('winston');
|
|
4
|
+
const Sockets = nodebb.require('./src/socket.io');
|
|
5
|
+
const pubsub = nodebb.require('./src/pubsub');
|
|
6
|
+
const user = nodebb.require('./src/user');
|
|
7
|
+
const { defaults, breakingSettings } = require('./defaults');
|
|
8
|
+
|
|
9
|
+
// Sockets currently joined to 'admin/plugins/meilisearch' (set by app.enterRoom in ACP).
|
|
10
|
+
// NodeBB 4.15.0 SocketMeta.rooms.enter only guards uid_/chat_/topic_/category_ prefixes
|
|
11
|
+
// (verified in NodeBB v4.15.0 src/socket.io/meta.js) — arbitrary room names like
|
|
12
|
+
// 'admin/plugins/meilisearch' are freely joinable by any authenticated user. Filter by
|
|
13
|
+
// isAdministrator before emitting, so alert payloads (which may contain embedder endpoint
|
|
14
|
+
// URLs / model names from Meilisearch task error messages) don't reach authenticated
|
|
15
|
+
// non-admins who manually joined the room. isAdministrator is Redis-cached for 60s by
|
|
16
|
+
// NodeBB core (src/user.js), so for low-frequency alert emissions the overhead is sub-ms.
|
|
17
|
+
async function emitToAdmins(eventName, payload) {
|
|
18
|
+
const sockets = await Sockets.server.in('admin/plugins/meilisearch').fetchSockets();
|
|
19
|
+
if (!sockets.length) return;
|
|
20
|
+
const adminChecks = await Promise.all(
|
|
21
|
+
// Use s.data.uid (cluster-safe — socket.data is shared across nodes via fetchSockets,
|
|
22
|
+
// verified in NodeBB v4.15.0 src/socket.io/index.js:87-88). s.uid only exists on the
|
|
23
|
+
// local node, so in a multi-node cluster, s.uid would be undefined for remote sockets,
|
|
24
|
+
// silently defeating the admin-only filter.
|
|
25
|
+
sockets.map(s => (s.data && s.data.uid ? user.isAdministrator(s.data.uid) : Promise.resolve(false))),
|
|
26
|
+
);
|
|
27
|
+
sockets.forEach((s, i) => {
|
|
28
|
+
if (adminChecks[i]) s.emit(eventName, payload);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Sets up the plugin object's mutable state and cross-instance sync via pubsub.
|
|
33
|
+
// Must run before any other lib/* attach step since everything else reads/writes these fields.
|
|
34
|
+
module.exports = function initState(plugin) {
|
|
35
|
+
plugin.id = 'meilisearch';
|
|
36
|
+
plugin.healthy = false;
|
|
37
|
+
|
|
38
|
+
plugin.indexing = {
|
|
39
|
+
running: false,
|
|
40
|
+
topic_progress: { total: null, current: null },
|
|
41
|
+
post_progress: { total: null, current: null },
|
|
42
|
+
message_progress: { total: null, current: null },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
plugin.reindexingForced = false;
|
|
46
|
+
plugin.pendingDuringReindex = [];
|
|
47
|
+
plugin.reindexLockRefresh = null;
|
|
48
|
+
|
|
49
|
+
/** @type {import('meilisearch').Meilisearch} */
|
|
50
|
+
plugin.client = undefined;
|
|
51
|
+
plugin.defaults = defaults;
|
|
52
|
+
plugin.breakingSettings = breakingSettings;
|
|
53
|
+
plugin.initialized = false;
|
|
54
|
+
plugin.initializingOnAnotherInstance = false;
|
|
55
|
+
plugin.healthCheckTask = null;
|
|
56
|
+
|
|
57
|
+
pubsub.on('meilisearch:reindex', (indexing) => {
|
|
58
|
+
plugin.indexing = indexing;
|
|
59
|
+
emitToAdmins('plugins.meilisearch.reindex', plugin.indexing)
|
|
60
|
+
.catch(err => winston.error(`[plugin/meilisearch] emitToAdmins reindex failed: ${err.message}`));
|
|
61
|
+
});
|
|
62
|
+
pubsub.on('meilisearch:init', (initializing) => {
|
|
63
|
+
plugin.initializingOnAnotherInstance = initializing;
|
|
64
|
+
});
|
|
65
|
+
pubsub.on('meilisearch:alert', (alert) => {
|
|
66
|
+
emitToAdmins('plugins.meilisearch.alert', alert)
|
|
67
|
+
.catch(err => winston.error(`[plugin/meilisearch] emitToAdmins alert failed: ${err.message}`));
|
|
68
|
+
});
|
|
69
|
+
};
|