wiki-plugin-similarity 0.6.0 → 0.8.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/client/similarity.js +1 -1
- package/package.json +4 -2
- package/pages/about-similarity-plugin +33 -3
- package/server/.galaxy-cache/wiki.ralfbarkow.ch/meta.json +1 -1
- package/server/embedder.js +27 -4
- package/server/peer-guard.js +156 -46
- package/server/server.js +157 -32
package/client/similarity.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* wiki-plugin-similarity - 0.
|
|
1
|
+
/* wiki-plugin-similarity - 0.8.0 - Tue, 21 Jul 2026 08:09:09 GMT */
|
|
2
2
|
(()=>{var q={high:.78,medium:.68,low:.58},M=q.medium,_=10,V=t=>{let e=[],n=[],o=[],f=null,b=null,d="search",y=!1,L=!1,E=null,k=null,w=(i,m)=>i===m||i.startsWith(m)&&/^[\s:]/.test(i.slice(m.length)),x=(i,m)=>i.slice(m.length).replace(/^\s*:?\s*/,"").trim();for(let i of t.split(`
|
|
3
3
|
`)){let m=i.trim();if(!m||m.startsWith("#"))continue;let g=m.toUpperCase();if(w(g,"LIVE")){y=!0;continue}if(w(g,"AUTHOR")){!e.length&&d==="search"&&(d="author");continue}if(w(g,"REPORT")){d==="search"&&(d="report");continue}if(w(g,"KEYWORD")){d==="search"&&(d="keyword");continue}if(w(g,"BUILD")){d==="search"&&(d="build");continue}if(w(g,"FORCE")){L=!0;continue}if(w(g,"GHOST")){E=x(m,"GHOST"),d==="search"&&(d="ghost");continue}if(w(g,"BUTTON")){k=x(m,"BUTTON");continue}if(w(g,"ROSTER")){let S=x(m,"ROSTER");S&&n.push(S);continue}if(w(g,"FARM")){let S=x(m,"FARM");S&&o.push(S);continue}if(w(g,"LIST")){!e.length&&d==="search"&&(d="list");continue}if(w(g,"SIMILAR")){let S=x(g,"SIMILAR").toLowerCase();f=q[S]||M,!e.length&&d==="search"&&(d="similar");continue}if(w(g,"THRESHOLD")){let S=x(m,"THRESHOLD");f=q[S.toLowerCase()]??(parseFloat(S)||M);continue}if(w(g,"LIMIT")){b=parseInt(x(m,"LIMIT"))||_;continue}e.push(["PUBLIC","LOCAL","PRIVATE"].includes(g)?g:m)}return{mode:d,specs:e,rosterRefs:n,farms:o,threshold:f??M,limit:b??_,live:y,force:L,ghostUrl:E,label:k,thresholdSet:f!==null}},et=t=>t.includes("*")||t.includes("?"),nt=t=>t==="PUBLIC"||t==="LOCAL"||t==="PRIVATE",st=t=>t.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,""),P=new Map,it=async(t,e)=>{if(P.has(t))return P.get(t);let n=`${e}/system/indexed-domains.json?pattern=${encodeURIComponent(t)}`,o=await fetch(n);if(!o.ok)throw new Error(`indexed-domains failed: ${o.status}`);let f=await o.json();return P.set(t,f),f},G=async(t,e)=>{t.length||(t=[window.location.hostname]);let n=new Set,o=[];for(let f of t)if(f==="*"||et(f)||nt(f))for(let b of await it(f,e))n.has(b.domain)||(n.add(b.domain),o.push(b));else n.has(f)||(n.add(f),o.push({domain:f,page_count:null}));return o},ot=/^([a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+|localhost)(:\d+)?$/,at=/^ROSTER ([A-Za-z0-9.\-:]+\/[a-z0-9-]+)$/,rt=/^REFERENCES ([A-Za-z0-9.\-:]+\/[a-z0-9-]+)$/,B=async t=>{let e=t.indexOf("/"),n=await fetch(`//${t.slice(0,e)}/${t.slice(e+1)}.json`);if(!n.ok)throw new Error(`roster page ${t} failed: ${n.status}`);return n.json()},K=async(t,e,n)=>{if(n.has(t))return;n.add(t);let o;try{o=await B(t)}catch(f){console.warn(f.message);return}for(let f of o.story||[])if(f.type==="roster")for(let b of(f.text||"").split(/\r?\n/)){let d=b.trim();if(!d)continue;let y=d.match(ot);if(y){e.add(y[0]);continue}let L=d.match(at);if(L){await K(L[1],e,n);continue}let E=d.match(rt);if(E)try{let k=await B(E[1]);for(let w of k.story||[])w.type==="reference"&&w.site&&e.add(w.site)}catch(k){console.warn(k.message)}}},lt=async t=>{let e=new Set,n=new Set;for(let o of t)await K(o,e,n);return[...e]},ct=async(t,e)=>e.length?[...t,...await lt(e)]:t,A=new Map,dt=t=>`${window.location.origin}/system/semantic-vectors.json?domain=${encodeURIComponent(t)}`,Z=async t=>{if(A.has(t))return A.get(t);let e=await fetch(dt(t));if(!e.ok)return[];let n=await e.json();return A.set(t,n),n},z=async(t,e)=>{let n=await fetch(`${e}/system/embed.json?text=${encodeURIComponent(t)}`);if(!n.ok)throw new Error(`embed failed: ${n.status}`);return(await n.json()).vector},ft=async(t,e)=>{let o=(await Z(e)).find(f=>f.slug===t);return o?o.vector:null},H=(t,e,{threshold:n,limit:o,excludeSlug:f,excludeDomain:b})=>{let d=[];for(let{domain:y,pages:L}of e)for(let{slug:E,title:k,vector:w}of L){if(E===f&&y===b)continue;let x=0;for(let i=0;i<t.length;i++)x+=t[i]*w[i];x>=n&&d.push({domain:y,slug:E,title:k,score:x})}return d.sort((y,L)=>L.score-y.score),d.slice(0,o)},F=async(t,e)=>{let n=await G(t,e);return(await Promise.all(n.map(async({domain:f})=>({domain:f,pages:await Z(f)})))).filter(f=>f.pages.length>0)},J=t=>`sim-cache-${t}`,mt=t=>{try{let e=JSON.parse(localStorage.getItem(J(t.id))||"null");return e?.text===(t.text||"")?e:null}catch{return null}},O=(t,e)=>{try{localStorage.setItem(J(t.id),JSON.stringify({text:t.text||"",ts:Date.now(),...e}))}catch{}},ut=t=>{let e=Math.floor((Date.now()-t)/1e3);return e<60?`${e}s ago`:e<3600?`${Math.floor(e/60)}m ago`:e<86400?`${Math.floor(e/3600)}h ago`:`${Math.floor(e/86400)}d ago`},I=`
|
|
4
4
|
.sim-form { display:flex; gap:6px; margin-bottom:8px; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-plugin-similarity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Federated Wiki plugin \u2014 semantic similarity search across wiki domains using prebuilt vector indexes",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"wiki",
|
|
@@ -42,8 +42,10 @@
|
|
|
42
42
|
"prepublishOnly": "npm run build"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
+
"minisearch": "^7.2.0"
|
|
46
|
+
},
|
|
47
|
+
"optionalDependencies": {
|
|
45
48
|
"@xenova/transformers": "^2.17.2",
|
|
46
|
-
"minisearch": "^7.2.0",
|
|
47
49
|
"onnxruntime-web": "1.14.0"
|
|
48
50
|
},
|
|
49
51
|
"devDependencies": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
{
|
|
10
10
|
"type": "markdown",
|
|
11
11
|
"id": "28edff984cc5334d",
|
|
12
|
-
"text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) — interactive semantic search form\n- `SIMILAR: high|medium|low` — ambient: pages similar to this page\n- `REPORT` — server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` — galactic MiniSearch over live site-index.json files\n- `LIST` — table of indexed domains\n- `AUTHOR` — search results as a draggable ghost page\n- `BUILD` / `FORCE` — proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` — open any page-json as a ghost page\n- `ROSTER site/slug` — add a roster page's sites to the scope (off-farm sites searched via the galaxy cache)\n- `FARM domain` — ask a peer farm to continue the search (
|
|
12
|
+
"text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) — interactive semantic search form\n- `SIMILAR: high|medium|low` — ambient: pages similar to this page\n- `REPORT` — server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` — galactic MiniSearch over live site-index.json files\n- `LIST` — table of indexed domains\n- `AUTHOR` — search results as a draggable ghost page\n- `BUILD` / `FORCE` — proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` — open any page-json as a ghost page\n- `ROSTER site/slug` — add a roster page's sites to the scope (off-farm sites searched via the galaxy cache)\n- `FARM domain` — ask a peer farm to continue the search. The peer answers from exactly the sites whose own [[Federated Farm Search]] page grants your origin (`FROM` lines; `FROM *` needs the peer admin's `WIKI_PEER_FEDERATION=open`)\n- `THRESHOLD: 0.72` · `LIMIT: 8` · `LIVE` — tuning"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"type": "markdown",
|
|
@@ -54,12 +54,12 @@
|
|
|
54
54
|
{
|
|
55
55
|
"type": "markdown",
|
|
56
56
|
"id": "d9a96dfebb9d1692",
|
|
57
|
-
"text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n- `GET /system/galaxy-search.json?q=&sites=` — keyword search over off-farm federation sites\n- `POST /system/peer-search.json` — answer another farm's search
|
|
57
|
+
"text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n- `GET /system/galaxy-search.json?q=&sites=` — keyword search over off-farm federation sites\n- `GET /system/peer-hello.json` — capability probe: version, model, federation status (404 = plugin absent)\n- `POST /system/peer-search.json` — answer another farm's search. Two keys: the admin's `WIKI_PEER_FEDERATION` ceiling (off / grants / open) and each site's Federated Farm Search grants page; scope = union of granting sites; hop limit, replay dedup, IP-keyed + global rate limits; restricted sites always excluded\n\nIndexes are `status/semantic-vectors.json` per domain — built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised — byte-compatible with the index builder."
|
|
58
58
|
},
|
|
59
59
|
{
|
|
60
60
|
"type": "markdown",
|
|
61
61
|
"id": "21eb5354d2adb29e",
|
|
62
|
-
"text": "**Version:** 0.
|
|
62
|
+
"text": "**Version:** 0.7.0\n\n# Links\n\n- [https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity GitHub repository]\n- [https://www.npmjs.com/package/wiki-plugin-similarity npm package]\n- [https://plugin.fedwiki.club/similarity-plugin.html Plugin documentation]\n- [https://search.fedwiki.club/ Search hub] — architecture, indexing, freshness."
|
|
63
63
|
}
|
|
64
64
|
],
|
|
65
65
|
"plugin": "similarity",
|
|
@@ -163,6 +163,36 @@
|
|
|
163
163
|
"text": "**Version:** 0.6.0\n\n# Links\n\n- [https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity GitHub repository]\n- [https://www.npmjs.com/package/wiki-plugin-similarity npm package]\n- [https://plugin.fedwiki.club/similarity-plugin.html Plugin documentation]\n- [https://search.fedwiki.club/ Search hub] — architecture, indexing, freshness."
|
|
164
164
|
},
|
|
165
165
|
"date": 1783800718188
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"type": "edit",
|
|
169
|
+
"id": "28edff984cc5334d",
|
|
170
|
+
"item": {
|
|
171
|
+
"type": "markdown",
|
|
172
|
+
"id": "28edff984cc5334d",
|
|
173
|
+
"text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) — interactive semantic search form\n- `SIMILAR: high|medium|low` — ambient: pages similar to this page\n- `REPORT` — server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` — galactic MiniSearch over live site-index.json files\n- `LIST` — table of indexed domains\n- `AUTHOR` — search results as a draggable ghost page\n- `BUILD` / `FORCE` — proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` — open any page-json as a ghost page\n- `ROSTER site/slug` — add a roster page's sites to the scope (off-farm sites searched via the galaxy cache)\n- `FARM domain` — ask a peer farm to continue the search. The peer answers from exactly the sites whose own [[Federated Farm Search]] page grants your origin (`FROM` lines; `FROM *` needs the peer admin's `WIKI_PEER_FEDERATION=open`)\n- `THRESHOLD: 0.72` · `LIMIT: 8` · `LIVE` — tuning"
|
|
174
|
+
},
|
|
175
|
+
"date": 1784591495272
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"type": "edit",
|
|
179
|
+
"id": "d9a96dfebb9d1692",
|
|
180
|
+
"item": {
|
|
181
|
+
"type": "markdown",
|
|
182
|
+
"id": "d9a96dfebb9d1692",
|
|
183
|
+
"text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n- `GET /system/galaxy-search.json?q=&sites=` — keyword search over off-farm federation sites\n- `GET /system/peer-hello.json` — capability probe: version, model, federation status (404 = plugin absent)\n- `POST /system/peer-search.json` — answer another farm's search. Two keys: the admin's `WIKI_PEER_FEDERATION` ceiling (off / grants / open) and each site's Federated Farm Search grants page; scope = union of granting sites; hop limit, replay dedup, IP-keyed + global rate limits; restricted sites always excluded\n\nIndexes are `status/semantic-vectors.json` per domain — built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised — byte-compatible with the index builder."
|
|
184
|
+
},
|
|
185
|
+
"date": 1784591495272
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"type": "edit",
|
|
189
|
+
"id": "21eb5354d2adb29e",
|
|
190
|
+
"item": {
|
|
191
|
+
"type": "markdown",
|
|
192
|
+
"id": "21eb5354d2adb29e",
|
|
193
|
+
"text": "**Version:** 0.7.0\n\n# Links\n\n- [https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity GitHub repository]\n- [https://www.npmjs.com/package/wiki-plugin-similarity npm package]\n- [https://plugin.fedwiki.club/similarity-plugin.html Plugin documentation]\n- [https://search.fedwiki.club/ Search hub] — architecture, indexing, freshness."
|
|
194
|
+
},
|
|
195
|
+
"date": 1784591495272
|
|
166
196
|
}
|
|
167
197
|
]
|
|
168
198
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"sitemap.json":{"lastModified":"Fri, 10 Jul 2026 05:01:01 GMT","fetchedAt":
|
|
1
|
+
{"sitemap.json":{"lastModified":"Fri, 10 Jul 2026 05:01:01 GMT","fetchedAt":1783851444700},"site-index.json":{"lastModified":"Fri, 10 Jul 2026 05:01:50 GMT","fetchedAt":1783851444709}}
|
package/server/embedder.js
CHANGED
|
@@ -19,6 +19,21 @@ const MODEL = process.env.WIKI_EMBED_MODEL || 'Xenova/bge-small-en-v1.5'
|
|
|
19
19
|
const POOLING = process.env.WIKI_EMBED_POOLING || 'cls'
|
|
20
20
|
const QUANTIZED = process.env.WIKI_EMBED_QUANTIZED === '1'
|
|
21
21
|
|
|
22
|
+
// Prefer wiki-plugin-semindex's shared worker-thread embedder when installed:
|
|
23
|
+
// one model instance per process (shared with bulk indexing), inference off
|
|
24
|
+
// the event loop, and similarity's own heavy deps become optional. The
|
|
25
|
+
// sibling path works for npm installs (wiki/node_modules/*) and dev symlinks
|
|
26
|
+
// (~/Code/wiki-plugins/*) alike; the bare require covers other layouts.
|
|
27
|
+
const semindexLib = (() => {
|
|
28
|
+
const path = require('node:path')
|
|
29
|
+
const fs = require('node:fs')
|
|
30
|
+
try {
|
|
31
|
+
const sibling = path.resolve(__dirname, '../../wiki-plugin-semindex/server/embed-lib.js')
|
|
32
|
+
if (fs.existsSync(sibling)) return require(sibling)
|
|
33
|
+
return require('wiki-plugin-semindex/server/embed-lib.js')
|
|
34
|
+
} catch { return null }
|
|
35
|
+
})()
|
|
36
|
+
|
|
22
37
|
// transformers.js statically imports onnxruntime-node, whose native binary
|
|
23
38
|
// needs glibc. On musl containers (node:alpine — the public farm) that import
|
|
24
39
|
// throws "Error loading shared library ld-linux-x86-64.so.2". Probe the
|
|
@@ -47,7 +62,13 @@ const getExtractor = () => {
|
|
|
47
62
|
console.log('[wiki-plugin-similarity] native onnxruntime unavailable — using WASM backend')
|
|
48
63
|
}
|
|
49
64
|
// Dynamic import: @xenova/transformers is ESM-only; this file must stay CJS.
|
|
50
|
-
|
|
65
|
+
// Since 0.8.0 the heavy deps are optionalDependencies — absent on slim
|
|
66
|
+
// installs, where semindex delegation or WIKI_EMBED_URL is the path.
|
|
67
|
+
const { pipeline, env } = await import('@xenova/transformers').catch(() => {
|
|
68
|
+
throw new Error('no local embedder: @xenova/transformers is not installed. ' +
|
|
69
|
+
'Install wiki-plugin-semindex, set WIKI_EMBED_URL, or reinstall ' +
|
|
70
|
+
'wiki-plugin-similarity with optional dependencies.')
|
|
71
|
+
})
|
|
51
72
|
if (process.env.WIKI_MODEL_CACHE) env.cacheDir = process.env.WIKI_MODEL_CACHE
|
|
52
73
|
if (env.backends?.onnx?.wasm) env.backends.onnx.wasm.numThreads = 1
|
|
53
74
|
return pipeline('feature-extraction', MODEL, { quantized: QUANTIZED })
|
|
@@ -59,13 +80,15 @@ const getExtractor = () => {
|
|
|
59
80
|
|
|
60
81
|
// Embed one text → plain number[384], unit-normalised.
|
|
61
82
|
const embed = async text => {
|
|
83
|
+
if (semindexLib) return semindexLib.embed(text)
|
|
62
84
|
const extractor = await getExtractor()
|
|
63
85
|
const out = await extractor(text, { pooling: POOLING, normalize: true })
|
|
64
86
|
return Array.from(out.data)
|
|
65
87
|
}
|
|
66
88
|
|
|
67
89
|
// Warm the model in the background (call at startup; failures just defer to
|
|
68
|
-
// first request).
|
|
69
|
-
|
|
90
|
+
// first request). With semindex delegation, don't warm — its worker spawns on
|
|
91
|
+
// demand and self-terminates when idle; pre-warming would just pin ~0.5GB.
|
|
92
|
+
const warm = () => { if (!semindexLib) getExtractor().catch(() => {}) }
|
|
70
93
|
|
|
71
|
-
module.exports = { embed, warm }
|
|
94
|
+
module.exports = { embed, warm, viaSemindex: !!semindexLib }
|
package/server/peer-guard.js
CHANGED
|
@@ -1,41 +1,139 @@
|
|
|
1
1
|
// wiki-plugin-similarity — peer-search guards (CommonJS)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// served only when every guard passes:
|
|
5
|
-
// - allowlist: the requesting farm appears in the serving site's own
|
|
6
|
-
// `search-peers` page (a roster item — configuration as a wiki page)
|
|
7
|
-
// - hops: v1 answers locally only, never re-forwards (hops must be 0)
|
|
8
|
-
// - dedup: a request-id LRU drops replayed/looped envelopes
|
|
9
|
-
// - rate: a small per-origin token bucket protects the embedder
|
|
3
|
+
// Two keys must turn before a farm answers a peer's search:
|
|
10
4
|
//
|
|
11
|
-
// The
|
|
12
|
-
//
|
|
5
|
+
// 1. The farm admin's ceiling — WIKI_PEER_FEDERATION env:
|
|
6
|
+
// off refuse all peer search (kill switch)
|
|
7
|
+
// grants per-site owner grants honored; FROM * is inert (default)
|
|
8
|
+
// open FROM * takes effect
|
|
9
|
+
// An env, not a page: the plugin cannot verify who wrote a page line,
|
|
10
|
+
// and the environment is the one thing only the admin holds.
|
|
11
|
+
//
|
|
12
|
+
// 2. The site owner's grant — a "Federated Farm Search" page (slug
|
|
13
|
+
// federated-farm-search, override WIKI_PEER_GRANTS_PAGE) whose CODE
|
|
14
|
+
// items carry FROM lines:
|
|
15
|
+
// FROM hitchhikers.earth a named partner
|
|
16
|
+
// FROM *.fedwiki.org a glob family
|
|
17
|
+
// FROM * everyone (needs ceiling = open)
|
|
18
|
+
// Lines are additive. No page and no legacy search-peers roster means
|
|
19
|
+
// the site is closed to peers — opt-in, never opt-out.
|
|
20
|
+
//
|
|
21
|
+
// The answer scope for an origin is exactly the union of sites that grant
|
|
22
|
+
// it (per-site consent — one site's grant never opens the whole farm).
|
|
23
|
+
// Restricted (login-to-view) sites are excluded below this layer,
|
|
24
|
+
// unconditionally.
|
|
25
|
+
//
|
|
26
|
+
// Migration: a site with no grants page but a legacy `search-peers` roster
|
|
27
|
+
// page has its roster lines read as FROM lines for that site only.
|
|
28
|
+
// Remove the fallback at 0.8.
|
|
29
|
+
//
|
|
30
|
+
// Rate limiting is keyed by remote IP, never by the asserted origin (origins
|
|
31
|
+
// are free to invent in v1), with a bounded LRU and a global cap.
|
|
32
|
+
//
|
|
33
|
+
// This guard seam stays narrow on purpose: v2 swaps the grant check for
|
|
34
|
+
// shared-secret HMAC verification, v3 for trust-graph attestation, without
|
|
13
35
|
// touching the callers.
|
|
14
36
|
|
|
15
37
|
const fs = require('node:fs')
|
|
16
38
|
const path = require('node:path')
|
|
17
39
|
|
|
40
|
+
const { globMatch } = require('./farm-lib')
|
|
41
|
+
|
|
18
42
|
const SITE_LINE = /^([a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+|localhost)(:\d+)?$/
|
|
19
43
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
const CEILINGS = ['off', 'grants', 'open']
|
|
45
|
+
const ceiling = () => {
|
|
46
|
+
const c = (process.env.WIKI_PEER_FEDERATION || 'grants').toLowerCase()
|
|
47
|
+
return CEILINGS.includes(c) ? c : 'grants'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const grantsSlug = () => process.env.WIKI_PEER_GRANTS_PAGE || 'federated-farm-search'
|
|
51
|
+
|
|
52
|
+
// ── Grants parser ─────────────────────────────────────────────────────────────
|
|
53
|
+
// Input: the text of one or more code items. Output:
|
|
54
|
+
// { open: bool, origins: Set<string>, globs: string[] }
|
|
55
|
+
// Unknown UPPERCASE lines are ignored (safe while every command is
|
|
56
|
+
// additive-permissive; a future restricting command must be version-gated
|
|
57
|
+
// via the hello route).
|
|
58
|
+
|
|
59
|
+
const parseGrants = texts => {
|
|
60
|
+
const grants = { open: false, origins: new Set(), globs: [] }
|
|
61
|
+
for (const text of texts) {
|
|
62
|
+
for (const raw of (text || '').split(/\r?\n/)) {
|
|
63
|
+
const line = raw.trim()
|
|
64
|
+
if (!line || line.startsWith('#')) continue
|
|
65
|
+
const m = line.match(/^FROM[\s:]+(.+)$/i)
|
|
66
|
+
if (!m) continue
|
|
67
|
+
const value = m[1].trim()
|
|
68
|
+
if (value === '*') grants.open = true
|
|
69
|
+
else if (value.includes('*') || value.includes('?')) grants.globs.push(value)
|
|
70
|
+
else if (value.match(SITE_LINE)) grants.origins.add(value)
|
|
35
71
|
}
|
|
36
|
-
return allowed
|
|
37
72
|
}
|
|
38
|
-
return
|
|
73
|
+
return grants
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Does this grant set admit the origin, under the admin ceiling?
|
|
77
|
+
const grantsAdmit = (grants, origin, ceil = ceiling()) => {
|
|
78
|
+
if (!grants || ceil === 'off') return false
|
|
79
|
+
if (grants.origins.has(origin)) return true
|
|
80
|
+
if (grants.globs.some(g => globMatch(g, origin))) return true
|
|
81
|
+
if (grants.open && ceil === 'open') return true
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Per-site grant reads, mtime-cached ────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const grantCache = new Map() // "farm/domain" → {mtime, grants|null}
|
|
88
|
+
|
|
89
|
+
const readCodeItemTexts = page =>
|
|
90
|
+
(page.story || []).filter(i => i.type === 'code').map(i => i.text || '')
|
|
91
|
+
|
|
92
|
+
const readRosterSiteLines = page => {
|
|
93
|
+
const out = new Set()
|
|
94
|
+
for (const item of page.story || []) {
|
|
95
|
+
if (item.type !== 'roster') continue
|
|
96
|
+
for (const raw of (item.text || '').split(/\r?\n/)) {
|
|
97
|
+
const m = raw.trim().match(SITE_LINE)
|
|
98
|
+
if (m) out.add(m[0])
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Grants for one site: the federated-farm-search page's code items, falling
|
|
105
|
+
// back to the legacy search-peers roster (read as FROM lines for this site).
|
|
106
|
+
// Returns null when the site declares nothing (closed).
|
|
107
|
+
const readSiteGrants = (farm, domain) => {
|
|
108
|
+
const tryPage = (slug, extract) => {
|
|
109
|
+
const p = path.join(farm, domain, 'pages', slug)
|
|
110
|
+
let mtime
|
|
111
|
+
try { mtime = fs.statSync(p).mtimeMs } catch { return undefined } // no page
|
|
112
|
+
const key = `${farm}/${domain}/${slug}`
|
|
113
|
+
const hit = grantCache.get(key)
|
|
114
|
+
if (hit && hit.mtime === mtime) return hit.grants
|
|
115
|
+
let grants = null
|
|
116
|
+
try { grants = extract(JSON.parse(fs.readFileSync(p, 'utf8'))) } catch { /* unreadable */ }
|
|
117
|
+
grantCache.set(key, { mtime, grants })
|
|
118
|
+
if (grantCache.size > 2000) grantCache.delete(grantCache.keys().next().value)
|
|
119
|
+
return grants
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const fromPage = tryPage(grantsSlug(), page => parseGrants(readCodeItemTexts(page)))
|
|
123
|
+
if (fromPage !== undefined) return fromPage
|
|
124
|
+
const legacy = tryPage('search-peers', page => {
|
|
125
|
+
const origins = readRosterSiteLines(page)
|
|
126
|
+
return origins.size ? { open: false, origins, globs: [] } : null
|
|
127
|
+
})
|
|
128
|
+
return legacy !== undefined ? legacy : null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The union of domains (from [{farm, domain}]) whose own grants admit the
|
|
132
|
+
// origin under the current ceiling.
|
|
133
|
+
const grantingDomains = (domains, origin, ceil = ceiling()) => {
|
|
134
|
+
if (ceil === 'off') return []
|
|
135
|
+
return domains.filter(({ farm, domain }) =>
|
|
136
|
+
grantsAdmit(readSiteGrants(farm, domain), origin, ceil))
|
|
39
137
|
}
|
|
40
138
|
|
|
41
139
|
// ── Request-id LRU dedup ──────────────────────────────────────────────────────
|
|
@@ -51,16 +149,24 @@ const makeDedup = (max = 500) => {
|
|
|
51
149
|
}
|
|
52
150
|
}
|
|
53
151
|
|
|
54
|
-
// ──
|
|
152
|
+
// ── Rate limiting: per-IP token buckets (bounded LRU) + a global bucket ──────
|
|
153
|
+
// Never key limits by the asserted origin: a spoofer rotating origins would
|
|
154
|
+
// both escape the limiter and grow the map without bound.
|
|
55
155
|
|
|
56
|
-
const makeBucket = ({ capacity = 30, refillPerSec = 0.5 } = {}) => {
|
|
156
|
+
const makeBucket = ({ capacity = 30, refillPerSec = 0.5, maxKeys = 500 } = {}) => {
|
|
57
157
|
const buckets = new Map()
|
|
58
|
-
return
|
|
158
|
+
return key => {
|
|
59
159
|
const now = Date.now()
|
|
60
|
-
let b = buckets.get(
|
|
61
|
-
if (!b) {
|
|
62
|
-
|
|
63
|
-
|
|
160
|
+
let b = buckets.get(key)
|
|
161
|
+
if (!b) {
|
|
162
|
+
b = { tokens: capacity, at: now }
|
|
163
|
+
} else {
|
|
164
|
+
buckets.delete(key) // refresh LRU position
|
|
165
|
+
b.tokens = Math.min(capacity, b.tokens + ((now - b.at) / 1000) * refillPerSec)
|
|
166
|
+
b.at = now
|
|
167
|
+
}
|
|
168
|
+
buckets.set(key, b)
|
|
169
|
+
while (buckets.size > maxKeys) buckets.delete(buckets.keys().next().value)
|
|
64
170
|
if (b.tokens < 1) return false
|
|
65
171
|
b.tokens -= 1
|
|
66
172
|
return true
|
|
@@ -68,29 +174,33 @@ const makeBucket = ({ capacity = 30, refillPerSec = 0.5 } = {}) => {
|
|
|
68
174
|
}
|
|
69
175
|
|
|
70
176
|
// ── Envelope guard ────────────────────────────────────────────────────────────
|
|
177
|
+
// Shape and abuse checks only — the consent decision (which sites grant this
|
|
178
|
+
// origin) is per-site and belongs to the caller via grantingDomains().
|
|
71
179
|
// Returns {ok: true} or {ok: false, code, error}.
|
|
72
180
|
|
|
73
|
-
const guardEnvelope = (envelope, {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return { ok: false, code: 400, error: 'origin required' }
|
|
77
|
-
}
|
|
78
|
-
if (allowed === null) {
|
|
79
|
-
return { ok: false, code: 403, error: 'this site has no search-peers page — peer search is not enabled here' }
|
|
181
|
+
const guardEnvelope = (envelope, { ip, isDuplicate, takeIpToken, takeGlobalToken }) => {
|
|
182
|
+
if (ceiling() === 'off') {
|
|
183
|
+
return { ok: false, code: 403, error: 'peer federation is switched off on this farm' }
|
|
80
184
|
}
|
|
81
|
-
if (!
|
|
82
|
-
return { ok: false, code:
|
|
185
|
+
if (!(envelope.origin || '').trim()) {
|
|
186
|
+
return { ok: false, code: 400, error: 'origin required' }
|
|
83
187
|
}
|
|
84
188
|
if ((envelope.hops || 0) > 0) {
|
|
85
|
-
return { ok: false, code: 400, error: 'relayed requests are not accepted (
|
|
189
|
+
return { ok: false, code: 400, error: 'relayed requests are not accepted (answers are local only)' }
|
|
86
190
|
}
|
|
87
191
|
if (isDuplicate(envelope.requestId)) {
|
|
88
192
|
return { ok: false, code: 409, error: 'duplicate request-id (replay or loop)' }
|
|
89
193
|
}
|
|
90
|
-
if (!
|
|
91
|
-
return { ok: false, code: 429, error: '
|
|
194
|
+
if (takeGlobalToken && !takeGlobalToken('global')) {
|
|
195
|
+
return { ok: false, code: 429, error: 'this farm is at its peer-search rate limit' }
|
|
196
|
+
}
|
|
197
|
+
if (!takeIpToken(ip || 'unknown')) {
|
|
198
|
+
return { ok: false, code: 429, error: 'rate limit exceeded' }
|
|
92
199
|
}
|
|
93
200
|
return { ok: true }
|
|
94
201
|
}
|
|
95
202
|
|
|
96
|
-
module.exports = {
|
|
203
|
+
module.exports = {
|
|
204
|
+
ceiling, grantsSlug, parseGrants, grantsAdmit, readSiteGrants, grantingDomains,
|
|
205
|
+
makeDedup, makeBucket, guardEnvelope, SITE_LINE,
|
|
206
|
+
}
|
package/server/server.js
CHANGED
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
// Proxy to the farm indexer (WIKI_INDEXER_URL) when configured; heavy
|
|
24
24
|
// embedding is the indexer's job (Pi5 on the Hitchhikers farm), never
|
|
25
25
|
// the wiki server's.
|
|
26
|
+
// GET /system/peer-hello.json
|
|
27
|
+
// Capability probe: plugin version, embedding model, federation status.
|
|
28
|
+
// 404 here means the plugin is absent from the farm.
|
|
29
|
+
// POST /system/peer-search.json
|
|
30
|
+
// Federated peer search. Two keys: the admin's WIKI_PEER_FEDERATION
|
|
31
|
+
// ceiling (off|grants|open) and each site's Federated Farm Search
|
|
32
|
+
// grants page (FROM lines). Scope = union of granting sites only.
|
|
26
33
|
//
|
|
27
34
|
// Farm root is derived from argv.status ({farm}/{domain}/status); extra farm
|
|
28
35
|
// roots come from WIKI_EXTRA_FARMS (colon-separated absolute paths).
|
|
@@ -44,9 +51,12 @@ const embedder = require('./embedder')
|
|
|
44
51
|
const { buildReport } = require('./search-report')
|
|
45
52
|
const { searchFarm, keywordReportPage } = require('./farm-search')
|
|
46
53
|
const { searchGalaxy } = require('./galaxy-search')
|
|
47
|
-
const {
|
|
54
|
+
const { ceiling, grantingDomains, makeDedup, makeBucket, guardEnvelope } = require('./peer-guard')
|
|
48
55
|
|
|
49
56
|
const MODEL_META = { model: 'BAAI/bge-small-en-v1.5', dim: 384 }
|
|
57
|
+
const PLUGIN_VERSION = (() => {
|
|
58
|
+
try { return require('../package.json').version } catch { return 'unknown' }
|
|
59
|
+
})()
|
|
50
60
|
|
|
51
61
|
// Optional external embedder (proxy) — unset means embed in-process.
|
|
52
62
|
const EMBED_URL = process.env.WIKI_EMBED_URL || null
|
|
@@ -205,16 +215,74 @@ const startServer = ({ argv, app }) => {
|
|
|
205
215
|
for (const route of ['/system/indexed-domains.json', '/system/semantic-vectors.json',
|
|
206
216
|
'/system/embed.json', '/system/search-report.json',
|
|
207
217
|
'/system/farm-search.json', '/system/build-index.json',
|
|
208
|
-
'/system/galaxy-search.json', '/system/peer-search.json'
|
|
218
|
+
'/system/galaxy-search.json', '/system/peer-search.json',
|
|
219
|
+
'/system/peer-hello.json']) {
|
|
209
220
|
app.options(route, (req, res) => { cors(res); res.sendStatus(204) })
|
|
210
221
|
}
|
|
211
222
|
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
const
|
|
223
|
+
// Peer federation guards (shared across peer requests). Rate limits are
|
|
224
|
+
// keyed by remote IP — never by the asserted origin — plus a global cap.
|
|
225
|
+
const isDuplicate = makeDedup()
|
|
226
|
+
const takeIpToken = makeBucket()
|
|
227
|
+
const takeGlobalToken = makeBucket({ capacity: 120, refillPerSec: 2, maxKeys: 1 })
|
|
228
|
+
|
|
229
|
+
const requestIp = req =>
|
|
230
|
+
(req.headers['x-forwarded-for'] || '').split(',')[0].trim() ||
|
|
231
|
+
req.socket.remoteAddress || 'unknown'
|
|
232
|
+
|
|
233
|
+
// GET JSON from a peer (hello probe) — same transport rules as postToPeer.
|
|
234
|
+
const getFromPeer = (peer, routePath, timeoutMs = 10_000) =>
|
|
235
|
+
new Promise((resolve, reject) => {
|
|
236
|
+
const isLocal = peer.endsWith('.localhost') || peer.startsWith('localhost')
|
|
237
|
+
const mod = isLocal ? http : https
|
|
238
|
+
const opts = {
|
|
239
|
+
hostname: peer.split(':')[0],
|
|
240
|
+
port: peer.includes(':') ? peer.split(':')[1] : (isLocal ? 80 : 443),
|
|
241
|
+
path: routePath,
|
|
242
|
+
timeout: timeoutMs,
|
|
243
|
+
}
|
|
244
|
+
if (isLocal) opts.lookup = (h, o, cb) => cb(null, [{ address: '127.0.0.1', family: 4 }])
|
|
245
|
+
const req = mod.get(opts, res => {
|
|
246
|
+
let data = ''
|
|
247
|
+
res.on('data', chunk => { data += chunk })
|
|
248
|
+
res.on('end', () => {
|
|
249
|
+
try { resolve({ status: res.statusCode, body: JSON.parse(data) }) }
|
|
250
|
+
catch { resolve({ status: res.statusCode, body: null }) }
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
req.on('timeout', () => { req.destroy(new Error('peer timeout')) })
|
|
254
|
+
req.on('error', reject)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// Hello probe cache: peer → {at, ok, body}. TTL 1h, small LRU.
|
|
258
|
+
const helloCache = new Map()
|
|
259
|
+
const HELLO_TTL = 60 * 60_000
|
|
260
|
+
|
|
261
|
+
const probePeer = async peer => {
|
|
262
|
+
const hit = helloCache.get(peer)
|
|
263
|
+
if (hit && Date.now() - hit.at < HELLO_TTL) return hit
|
|
264
|
+
let entry
|
|
265
|
+
try {
|
|
266
|
+
const { status, body } = await getFromPeer(peer, '/system/peer-hello.json')
|
|
267
|
+
entry = { at: Date.now(), ok: status === 200 && body && body.plugin, body }
|
|
268
|
+
} catch {
|
|
269
|
+
entry = { at: Date.now(), ok: false, body: null }
|
|
270
|
+
}
|
|
271
|
+
helloCache.set(peer, entry)
|
|
272
|
+
if (helloCache.size > 200) helloCache.delete(helloCache.keys().next().value)
|
|
273
|
+
return entry
|
|
274
|
+
}
|
|
215
275
|
|
|
216
276
|
const askPeers = async (peers, envelope) => {
|
|
217
277
|
const outcomes = await Promise.all(peers.map(async peer => {
|
|
278
|
+
const hello = await probePeer(peer)
|
|
279
|
+
if (!hello.ok) {
|
|
280
|
+
return { peer, status: 0,
|
|
281
|
+
body: { error: 'peer does not answer hello — no similarity federation there' } }
|
|
282
|
+
}
|
|
283
|
+
if (hello.body.federation && hello.body.federation.enabled === false) {
|
|
284
|
+
return { peer, status: 0, body: { error: 'peer has federation switched off' } }
|
|
285
|
+
}
|
|
218
286
|
try {
|
|
219
287
|
const { status, body } = await postToPeer(peer, '/system/peer-search.json', envelope)
|
|
220
288
|
return { peer, status, body }
|
|
@@ -365,31 +433,67 @@ const startServer = ({ argv, app }) => {
|
|
|
365
433
|
}
|
|
366
434
|
})
|
|
367
435
|
|
|
368
|
-
// ──
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
436
|
+
// ── GET /system/peer-hello.json — capability probe ────────────────────────
|
|
437
|
+
// "Does this farm run the Similarity plugin, and does it federate?"
|
|
438
|
+
// A 404 (or HTML) here means the plugin is absent. Static JSON, no embedder
|
|
439
|
+
// cost; does not advertise any FROM list.
|
|
440
|
+
app.get('/system/peer-hello.json', (req, res) => {
|
|
441
|
+
cors(res)
|
|
442
|
+
res.setHeader('Cache-Control', 'public, max-age=3600')
|
|
443
|
+
res.json({
|
|
444
|
+
plugin: 'wiki-plugin-similarity',
|
|
445
|
+
version: PLUGIN_VERSION,
|
|
446
|
+
...MODEL_META,
|
|
447
|
+
site: req.hostname,
|
|
448
|
+
federation: {
|
|
449
|
+
enabled: ceiling() !== 'off',
|
|
450
|
+
kinds: ['report', 'keyword'],
|
|
451
|
+
hopsAccepted: 0,
|
|
452
|
+
},
|
|
453
|
+
})
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
// ── POST /system/peer-search.json — federated peer search ────────────────
|
|
457
|
+
// Two keys must turn: the farm admin's WIKI_PEER_FEDERATION ceiling, and
|
|
458
|
+
// each site's own Federated Farm Search grants page. The answer scope for
|
|
459
|
+
// an origin is exactly the union of sites that granted it — one site's
|
|
460
|
+
// grant never opens the whole farm. Restricted (login-to-view) sites are
|
|
461
|
+
// excluded unconditionally. Abuse guards: hop limit, request-id dedup,
|
|
462
|
+
// IP-keyed + global rate limits.
|
|
373
463
|
app.post('/system/peer-search.json', async (req, res) => {
|
|
374
464
|
cors(res)
|
|
375
465
|
try {
|
|
376
466
|
const envelope = await readBody(req)
|
|
377
|
-
const
|
|
378
|
-
|
|
467
|
+
const verdict = guardEnvelope(envelope,
|
|
468
|
+
{ ip: requestIp(req), isDuplicate, takeIpToken, takeGlobalToken })
|
|
379
469
|
if (!verdict.ok) return res.status(verdict.code).json({ error: verdict.error })
|
|
380
470
|
if (!envelope.query) return res.status(400).json({ error: 'query required' })
|
|
381
471
|
|
|
472
|
+
const origin = envelope.origin.trim()
|
|
473
|
+
const allDomains = listDomains(farms, ['*'], restricted)
|
|
474
|
+
.filter(d => !restricted.has(d.domain))
|
|
475
|
+
const granted = grantingDomains(allDomains, origin)
|
|
476
|
+
if (!granted.length) {
|
|
477
|
+
return res.status(403).json({
|
|
478
|
+
error: `no site on this farm grants federated search to ${origin}`,
|
|
479
|
+
hint: 'a site opts in with FROM lines on its Federated Farm Search page',
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
const grantedNames = granted.map(d => d.domain)
|
|
483
|
+
|
|
382
484
|
const limit = envelope.limit || 10
|
|
383
485
|
let page
|
|
384
486
|
if (envelope.kind === 'keyword') {
|
|
385
|
-
const outcome = searchFarm(farms,
|
|
386
|
-
|
|
487
|
+
const outcome = searchFarm(farms, grantedNames, restricted,
|
|
488
|
+
envelope.query, limit, restricted)
|
|
489
|
+
page = keywordReportPage(envelope.query, outcome, limit, grantedNames)
|
|
387
490
|
} else {
|
|
388
|
-
page = await buildReport(envelope.query,
|
|
491
|
+
page = await buildReport(envelope.query, grantedNames, limit,
|
|
389
492
|
{ ...ctx, exclude: restricted })
|
|
390
493
|
}
|
|
391
494
|
const count = page.story.filter(i => i.type === 'reference').length
|
|
392
|
-
res.json({ page, meta: { ...MODEL_META,
|
|
495
|
+
res.json({ page, meta: { ...MODEL_META, version: PLUGIN_VERSION,
|
|
496
|
+
farm: req.hostname, sites: grantedNames.length, count } })
|
|
393
497
|
} catch (e) {
|
|
394
498
|
console.error('[wiki-plugin-similarity] peer-search error:', e.message)
|
|
395
499
|
res.status(500).json({ error: `peer-search failed: ${e.message}` })
|
|
@@ -397,26 +501,47 @@ const startServer = ({ argv, app }) => {
|
|
|
397
501
|
})
|
|
398
502
|
|
|
399
503
|
// ── GET /system/build-index.json?domains=…&force=… ────────────────────────
|
|
400
|
-
//
|
|
401
|
-
//
|
|
504
|
+
// Resolution order: WIKI_INDEXER_URL proxy (explicit config wins) → local
|
|
505
|
+
// wiki-plugin-semindex (in-process enqueue, owner/admin only) → 501.
|
|
506
|
+
const semindex = (() => {
|
|
507
|
+
try {
|
|
508
|
+
const sibling = path.resolve(__dirname, '../../wiki-plugin-semindex/server/indexer.js')
|
|
509
|
+
if (fs.existsSync(sibling)) return require(sibling)
|
|
510
|
+
return require('wiki-plugin-semindex/server/indexer.js')
|
|
511
|
+
} catch { return null }
|
|
512
|
+
})()
|
|
513
|
+
|
|
402
514
|
app.get('/system/build-index.json', async (req, res) => {
|
|
403
515
|
cors(res)
|
|
404
|
-
if (
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
516
|
+
if (INDEXER_URL) {
|
|
517
|
+
try {
|
|
518
|
+
const qs = new URLSearchParams({
|
|
519
|
+
domains: req.query.domains || '*',
|
|
520
|
+
force: req.query.force || '0',
|
|
521
|
+
})
|
|
522
|
+
return res.json(await getJson(`${INDEXER_URL}?${qs}`))
|
|
523
|
+
} catch (e) {
|
|
524
|
+
return res.status(502).json({ error: `farm indexer unavailable: ${e.message}` })
|
|
525
|
+
}
|
|
410
526
|
}
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
})
|
|
416
|
-
res.
|
|
417
|
-
|
|
418
|
-
|
|
527
|
+
if (semindex) {
|
|
528
|
+
const sh = app.securityhandler
|
|
529
|
+
const ok = (() => {
|
|
530
|
+
try { return !!sh && (!!sh.isAdmin?.(req) || !!sh.isAuthorized?.(req)) } catch { return false }
|
|
531
|
+
})()
|
|
532
|
+
if (!ok) return res.sendStatus(403)
|
|
533
|
+
if (semindex.MODE !== 'writer') {
|
|
534
|
+
return res.status(409).json({ error: `semindex mode is '${semindex.MODE}' — not a writer` })
|
|
535
|
+
}
|
|
536
|
+
const queued = semindex.enqueueGlobs(req.query.domains || '*', req.query.force === '1')
|
|
537
|
+
return res.json({ queued, ...semindex.status() })
|
|
419
538
|
}
|
|
539
|
+
res.status(501).json({
|
|
540
|
+
error: 'no indexer available on this server',
|
|
541
|
+
hint: 'Install wiki-plugin-semindex for in-process indexing, or set ' +
|
|
542
|
+
'WIKI_INDEXER_URL to proxy to a farm indexer; indexes can also ' +
|
|
543
|
+
'arrive by sync.',
|
|
544
|
+
})
|
|
420
545
|
})
|
|
421
546
|
|
|
422
547
|
console.log('[wiki-plugin-similarity] routes registered')
|