@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.
@@ -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 };
@@ -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
+ };