wiki-plugin-hitchhiker 0.3.1 → 0.4.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/README.md CHANGED
@@ -39,24 +39,20 @@ surface. It installs:
39
39
  - **Page-flag drop target** — drop a URL / link / page-flag onto a flag: the favicon shows
40
40
  a loud pulsing highlight; on drop it opens a ghost **preview card** (see the server below).
41
41
 
42
- ## 3. Server component (`server/server.js`, ESM)
43
- `GET /system/unfurl.json?url=…` → a normalized **static** link-preview card.
44
- Since 0.3.0 the two extractor libraries are **imported on first request, not at
45
- start-up** — they cost about 14 MB of heap together, and a host that never
46
- unfurls should never pay it. Start-up cost of this server is now under 1 MB.
47
- `{ url, title, description, provider, author, type, image, favicon, source }`.
48
- - oEmbed metadata first (`@extractus/oembed-extractor`), OpenGraph/Twitter/JSON-LD fallback
49
- (`open-graph-scraper`). Metadata only — never the oEmbed HTML embed (no live third-party
50
- iframes/scripts).
51
- - The thumbnail is fetched **server-side and inlined as a `data:` URI**, so the card makes
52
- zero client→provider requests.
53
- - **SSRF-guarded**: public `http`/`https` only; private/loopback/link-local ranges and
54
- `*.localhost`/`.local`/`.internal` refused before any fetch. 30-min in-memory cache.
55
- Never throws — returns a minimal fallback card offline/on error.
42
+ ## 3. Server component (`server/server.js`, ESM) — two tiny routes, no dependencies
43
+ - `GET /system/farm-domains.json` → `{ local: [...], public: [...] }` — which domains this farm serves.
44
+ - `GET /system/outposts.json` → `{ unfurl: ["http://mini.private.fish", …] }` — which **Outposts** the
45
+ browser may ask for link previews. Read from `{status}/outpost.json` per site (wins), else the
46
+ `OUTPOST_URL` env (comma-separated), else `[]`. Cached by mtime; no restart to change.
56
47
 
57
- > ⚠️ **Private-farm only for now.** Before any public-farm deployment, harden the SSRF guard
58
- > against redirect-following (the extractor libs do their own fetch and may follow redirects
59
- > to internal hosts) and tighten image caps.
48
+ **Since 0.4.0 this plugin does no unfurling.** The link-preview builder — the two heavy extractor
49
+ libraries, ~14 MB of heap — is `wiki-plugin-unfurl`, installed on one well-resourced host (an
50
+ Outpost). `wiki-ux.js` calls that host **directly from the browser** (its route sets
51
+ `Access-Control-Allow-Origin: *`; a plain GET is a simple request, no preflight), shuffling the
52
+ list and trying each with a 2 s timeout. No Outpost, or none answering, means the drop report
53
+ renders without a card. Nothing logs. Absence is a normal state.
54
+
55
+ The list shape is deliberate: swapping the source for a DNS-SD SRV lookup later is one function.
60
56
 
61
57
  ## Install / build
62
58
  ```sh
package/client/wiki-ux.js CHANGED
@@ -1145,14 +1145,44 @@
1145
1145
 
1146
1146
  // Ask our own server to unfurl a URL into a static preview card (no third-party
1147
1147
  // calls from the browser; the thumbnail comes back inlined as a data URI).
1148
+ // Link previews come from an Outpost — a host running wiki-plugin-unfurl —
1149
+ // called straight from the browser. This farm only tells us which ones exist
1150
+ // (GET /system/outposts.json, a list). We shuffle and try each in turn with a
1151
+ // short timeout; the first card wins; every failure is swallowed. No Outpost,
1152
+ // or none answering, means null — and renderDropReport already draws a plain
1153
+ // report for a null card. Absence is normal, not an error.
1154
+ var _outposts = null
1155
+ function outposts() {
1156
+ if (_outposts) return _outposts
1157
+ _outposts = fetch(location.origin + '/system/outposts.json')
1158
+ .then(function (r) { return r.ok ? r.json() : {} })
1159
+ .then(function (j) { return (j && Array.isArray(j.unfurl) ? j.unfurl : []).filter(Boolean) })
1160
+ .catch(function () { return [] })
1161
+ return _outposts
1162
+ }
1163
+ function shuffle(a) {
1164
+ var b = a.slice()
1165
+ for (var i = b.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var t = b[i]; b[i] = b[j]; b[j] = t }
1166
+ return b
1167
+ }
1148
1168
  function unfurl(url) {
1149
- return fetch(location.origin + '/system/unfurl.json?url=' + encodeURIComponent(url))
1150
- .then(function (r) {
1151
- return r.ok ? r.json() : null
1152
- })
1153
- .catch(function () {
1154
- return null
1155
- })
1169
+ return outposts().then(function (list) {
1170
+ var order = shuffle(list)
1171
+ var tryNext = function (i) {
1172
+ if (i >= order.length) return null
1173
+ var ctl = typeof AbortController === 'function' ? new AbortController() : null
1174
+ var timer = ctl && setTimeout(function () { ctl.abort() }, 2000)
1175
+ return fetch(order[i] + '/system/unfurl.json?url=' + encodeURIComponent(url), ctl ? { signal: ctl.signal } : {})
1176
+ .then(function (r) { return r.ok ? r.json() : null })
1177
+ .catch(function () { return null })
1178
+ .then(function (card) {
1179
+ if (timer) clearTimeout(timer)
1180
+ if (card && typeof card === 'object' && 'title' in card) { card.outpost = order[i]; return card }
1181
+ return tryNext(i + 1)
1182
+ })
1183
+ }
1184
+ return tryNext(0)
1185
+ })
1156
1186
  }
1157
1187
 
1158
1188
  function cardHtml(card) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "wiki-plugin-hitchhiker",
3
- "version": "0.3.1",
4
- "description": "House UX + default-pages plugin for the hitchhiker farms: ships default pages (Tools, Changes to this Site) and hosts the wiki-ux.js client override (friendlier factory chooser + page-flag tool page), loaded globally by the wiki-security-hitchhiker bootstrap.",
3
+ "version": "0.4.0",
4
+ "description": "House UX + default-pages plugin for the hitchhiker farms: ships default pages (Tools, Changes to this Site), hosts the wiki-ux.js client override, and tells the browser which Outposts serve link previews. No dependencies; the heavy half is wiki-plugin-unfurl.",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "factory.json",
@@ -20,10 +20,6 @@
20
20
  "url": "https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-hitchhiker/issues"
21
21
  },
22
22
  "homepage": "https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-hitchhiker#readme",
23
- "dependencies": {
24
- "@extractus/oembed-extractor": "^4.1.0",
25
- "open-graph-scraper": "^6.12.0"
26
- },
27
23
  "fedwiki": {
28
24
  "api": {
29
25
  "version": 1,
package/server/server.js CHANGED
@@ -1,50 +1,25 @@
1
- // wiki-plugin-hitchhiker — server component (ESM)
1
+ // wiki-plugin-hitchhiker — server component. ESM.
2
2
  //
3
- // The farm runs Node v24 / wiki 0.40+, which loads plugin servers via import()
4
- // — so this matches wiki-plugin-similarity's ESM server (no server/package.json).
5
- // NOTE: for eventual publish to an older/public farm, revisit portability — an ESM
6
- // server silently 404s on wiki-server that uses require() + Node < 22.12.
3
+ // Two small routes and nothing else:
4
+ // GET /system/farm-domains.json — which domains this farm serves (local/public)
5
+ // GET /system/outposts.json — which Outposts the browser may ask for services
7
6
  //
8
- // GET /system/unfurl.json?url=<url>
9
- // Link-preview "unfurl" for a dropped URL. Returns a normalized STATIC card:
10
- // { url, title, description, provider, author, type, image, favicon, source }
11
- // - oEmbed metadata first (@extractus/oembed-extractor), OpenGraph/Twitter/JSON-LD
12
- // fallback (open-graph-scraper). We use metadata only — never the oEmbed html
13
- // embed — so nothing live/third-party is injected (static-cards-only).
14
- // - The thumbnail is fetched server-side and inlined as a data: URI, so the card
15
- // makes ZERO client→provider requests when the report renders.
16
- // - SSRF-guarded (public http/https only). In-memory TTL cache. Never throws to
17
- // the client: on any failure it returns a minimal card so a drop always works.
7
+ // Since 0.4.0 this plugin does no unfurling and carries no dependencies. Link
8
+ // previews are built by wiki-plugin-unfurl on an Outpost host, and the browser
9
+ // (client/wiki-ux.js) calls that host directly. This server's whole job for
10
+ // unfurl is to hand the browser the list.
18
11
  //
19
- // v1 scope: private farm (owner-only). Before public use, harden SSRF against
20
- // redirect-following: since 0.3.1 the OpenGraph and image fetches walk redirects
21
- // by hand and re-check every hop (safeFetch); the oEmbed endpoint hop remains
22
- // the library's own — see fromOembed.
12
+ // Discovery is a LIST of base URLs, tried in random order by the client —
13
+ // client-side round-robin, no registry:
14
+ // {status}/outpost.json { "unfurl": ["http://mini.private.fish", ...] } per site, wins
15
+ // OUTPOST_URL comma-separated farm default
16
+ // The list shape is deliberate: readOutposts() is the one function that changes
17
+ // when the source becomes a DNS-SD SRV lookup. Do not hardcode a single URL.
23
18
 
24
- import dns from 'node:dns/promises'
25
- import net from 'node:net'
26
19
  import fs from 'node:fs'
27
20
  import path from 'node:path'
28
21
  import os from 'node:os'
29
22
 
30
- // The two extractor libraries are imported on first use, not at start-up.
31
- // Together they load about 14 MB of heap — eighty per cent of the memory of
32
- // every plugin server on the farm combined — and this server is resident in
33
- // the shared process on every site of the host, whether or not anything ever
34
- // asks for a link preview. Lazy import means a host that never unfurls never
35
- // pays. Each import is awaited once and cached, so the second request costs
36
- // nothing; a failed import is not cached, so a transient error does not stick.
37
- let ogsP = null
38
- const ogs = (...a) => (ogsP ||= import('open-graph-scraper').then(m => m.default, e => { ogsP = null; throw e })).then(f => f(...a))
39
- let oembedP = null
40
- const oembedExtract = (...a) =>
41
- (oembedP ||= import('@extractus/oembed-extractor').then(m => m.extract, e => { oembedP = null; throw e })).then(f => f(...a))
42
-
43
- const CACHE = new Map()
44
- const TTL = 1000 * 60 * 30 // 30 minutes
45
- const FETCH_MS = 8000
46
- const MAX_IMAGE_BYTES = 300 * 1024
47
-
48
23
  // ── farm-domain listing ("is this host ours?") ────────────────────────────────
49
24
  // primary farm root = 'local' (the *.localhost private farm); extra roots = 'public'
50
25
  // (the Nextcloud mirror). A directory is a wiki domain if it contains a pages/ dir.
@@ -87,198 +62,46 @@ const listFarmDomains = primaryRoot => {
87
62
  return out
88
63
  }
89
64
 
90
- // ── SSRF guard ────────────────────────────────────────────────────────────────
91
- const isPrivateIp = ip => {
92
- if (net.isIP(ip) === 4) {
93
- const [a, b] = ip.split('.').map(Number)
94
- return (
95
- a === 0 ||
96
- a === 127 ||
97
- a === 10 ||
98
- (a === 172 && b >= 16 && b <= 31) ||
99
- (a === 192 && b === 168) ||
100
- (a === 169 && b === 254) ||
101
- a >= 224
102
- )
103
- }
104
- const s = ip.toLowerCase()
105
- return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
106
- }
107
65
 
108
- const assertPublicUrl = async raw => {
109
- const u = new URL(raw) // throws on bad url
110
- if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('protocol not allowed')
111
- const host = u.hostname.toLowerCase()
112
- if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal'))
113
- throw new Error('internal host blocked')
114
- // Literal-IP hosts are range-checked directly; names are resolved first.
115
- const addrs = net.isIP(host) ? [{ address: host }] : await dns.lookup(host, { all: true })
116
- for (const { address } of addrs) if (isPrivateIp(address)) throw new Error('private address blocked')
117
- return u
118
- }
119
-
120
- // ── a fetch that re-checks every hop ─────────────────────────────────────────
121
- // redirect:'follow' would let a public host 302 us onto 127.0.0.1 after the
122
- // guard has already passed the starting URL. So follow by hand: manual
123
- // redirects, assertPublicUrl on each Location, and a small hop cap.
124
- const MAX_HOPS = 4
125
- const safeFetch = async (raw, init = {}) => {
126
- let url = raw
127
- for (let hop = 0; hop <= MAX_HOPS; hop++) {
128
- await assertPublicUrl(url)
129
- const r = await fetch(url, { ...init, redirect: 'manual' })
130
- if (r.status >= 300 && r.status < 400) {
131
- const loc = r.headers.get('location')
132
- if (!loc) return r
133
- url = new URL(loc, url).href
134
- continue
135
- }
136
- return r
137
- }
138
- throw new Error('too many redirects')
139
- }
140
-
141
- export const __safeFetchForTest = (...a) => safeFetch(...a)
142
-
143
- // ── thumbnail → data URI (no client→provider request when the card renders) ───
144
- const inlineImage = async imgUrl => {
145
- if (!imgUrl) return ''
146
- try {
147
- const r = await safeFetch(imgUrl, { signal: AbortSignal.timeout(FETCH_MS) })
148
- if (!r.ok) return ''
149
- const type = r.headers.get('content-type') || ''
150
- if (!/^image\//i.test(type)) return ''
151
- const len = Number(r.headers.get('content-length') || 0)
152
- if (len && len > MAX_IMAGE_BYTES) return ''
153
- const buf = Buffer.from(await r.arrayBuffer())
154
- if (buf.length > MAX_IMAGE_BYTES) return ''
155
- return `data:${type.split(';')[0]};base64,${buf.toString('base64')}`
156
- } catch {
157
- return ''
158
- }
159
- }
160
-
161
- // ── extractors ────────────────────────────────────────────────────────────────
162
- const fromOembed = async url => {
163
- try {
164
- // Residual: the extractor discovers an oEmbed endpoint from the page and
165
- // fetches it with its own client. The starting URL is checked by the route,
166
- // the thumbnail it returns is re-checked by inlineImage, but the endpoint
167
- // hop itself is the library's. Acceptable on a host that only unfurls
168
- // public URLs; not acceptable as the only guard. Hence the redirect-checked
169
- // path for OpenGraph and the image, which are the two fetches we control.
170
- const d = await oembedExtract(url, {}, { signal: AbortSignal.timeout(FETCH_MS) })
171
- if (!d) return null
172
- return {
173
- title: d.title || d.author_name || '',
174
- description: '',
175
- provider: d.provider_name || '',
176
- author: d.author_name || '',
177
- type: d.type || 'rich',
178
- thumb: d.thumbnail_url || '',
179
- source: 'oembed',
180
- }
181
- } catch {
182
- return null // not an oEmbed provider (or fetch failed) → fall back
183
- }
184
- }
185
-
186
- const fromOpenGraph = async url => {
187
- try {
188
- // Walk redirects ourselves so every hop is checked, then hand the library
189
- // the final URL with redirects off. It cannot be led anywhere we did not look.
190
- const probe = await safeFetch(url, { method: 'GET', signal: AbortSignal.timeout(FETCH_MS) })
191
- const finalUrl = probe.url || url
192
- const r = await ogs({ url: finalUrl, fetchOptions: { signal: AbortSignal.timeout(FETCH_MS), redirect: 'manual' } })
193
- if (!r || r.error || !r.result) return null
194
- const x = r.result
195
- const img =
196
- (Array.isArray(x.ogImage) && x.ogImage[0] && x.ogImage[0].url) ||
197
- (Array.isArray(x.twitterImage) && x.twitterImage[0] && x.twitterImage[0].url) ||
198
- ''
199
- return {
200
- title: x.ogTitle || x.twitterTitle || x.dcTitle || '',
201
- description: x.ogDescription || x.twitterDescription || '',
202
- provider: x.ogSiteName || '',
203
- author: x.author || '',
204
- type: x.ogType || 'website',
205
- thumb: img,
206
- favicon: x.favicon || '',
207
- source: 'opengraph',
66
+ // ── Outposts ─────────────────────────────────────────────────────────────────
67
+ const cache = new Map() // file -> { mtimeMs, list }
68
+ const readOutposts = statusDir => {
69
+ const file = statusDir ? path.join(statusDir, 'outpost.json') : null
70
+ let list = []
71
+ if (file) {
72
+ let stat = null
73
+ try { stat = fs.statSync(file) } catch { cache.delete(file) }
74
+ if (stat) {
75
+ const hit = cache.get(file)
76
+ if (hit && hit.mtimeMs === stat.mtimeMs) list = hit.list
77
+ else {
78
+ try {
79
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'))
80
+ const v = j && j.unfurl
81
+ list = Array.isArray(v) ? v : typeof v === 'string' ? [v] : []
82
+ } catch (e) {
83
+ console.log(`caution: ${file}: ${e.message}`)
84
+ list = []
85
+ }
86
+ cache.set(file, { mtimeMs: stat.mtimeMs, list })
87
+ }
208
88
  }
209
- } catch {
210
- return null
211
89
  }
90
+ if (!list.length && process.env.OUTPOST_URL) list = process.env.OUTPOST_URL.split(',')
91
+ return list.map(s => String(s || '').trim().replace(/\/+$/, '')).filter(Boolean)
212
92
  }
213
93
 
214
- // ── route ─────────────────────────────────────────────────────────────────────
94
+ // ── routes ───────────────────────────────────────────────────────────────────
215
95
  export const startServer = ({ app, argv }) => {
216
- // argv.status = {farm}/{thisDomain}/status → primary farm root is two levels up.
217
96
  const primaryRoot = argv && argv.status ? path.dirname(path.dirname(argv.status)) : ''
218
- console.log('[wiki-plugin-hitchhiker] registering GET /system/unfurl.json + /system/farm-domains.json')
219
-
97
+ const statusDir = argv && argv.status ? argv.status : ''
98
+ console.log('[wiki-plugin-hitchhiker] registering GET /system/farm-domains.json + /system/outposts.json')
220
99
  const cors = res => {
221
100
  res.setHeader('Access-Control-Allow-Origin', '*')
222
101
  res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
223
102
  }
224
-
225
- app.options('/system/farm-domains.json', (req, res) => {
226
- cors(res)
227
- res.sendStatus(204)
228
- })
229
- app.get('/system/farm-domains.json', (req, res) => {
230
- cors(res)
231
- res.json(listFarmDomains(primaryRoot))
232
- })
233
-
234
- app.options('/system/unfurl.json', (req, res) => {
235
- cors(res)
236
- res.sendStatus(204)
237
- })
238
-
239
- app.get('/system/unfurl.json', async (req, res) => {
240
- cors(res)
241
- const url = String(req.query.url || '')
242
- let host = ''
243
- try {
244
- host = new URL(url).hostname
245
- } catch {}
246
-
247
- const hit = CACHE.get(url)
248
- if (hit && Date.now() - hit.t < TTL) return res.json(hit.v)
249
-
250
- let card
251
- try {
252
- await assertPublicUrl(url)
253
- const meta = (await fromOembed(url)) || (await fromOpenGraph(url)) || {}
254
- const image = await inlineImage(meta.thumb)
255
- card = {
256
- url,
257
- title: meta.title || host || url,
258
- description: meta.description || '',
259
- provider: meta.provider || host || '',
260
- author: meta.author || '',
261
- type: meta.type || 'link',
262
- image,
263
- favicon: meta.favicon || '',
264
- source: meta.source || 'fallback',
265
- }
266
- } catch (e) {
267
- // Graceful degradation (bad/blocked/offline URL) — always return a card.
268
- card = {
269
- url,
270
- title: host || url,
271
- description: '',
272
- provider: host,
273
- author: '',
274
- type: 'link',
275
- image: '',
276
- favicon: '',
277
- source: 'error',
278
- error: String((e && e.message) || e),
279
- }
280
- }
281
- CACHE.set(url, { t: Date.now(), v: card })
282
- res.json(card)
283
- })
103
+ app.options('/system/farm-domains.json', (req, res) => { cors(res); res.sendStatus(204) })
104
+ app.get('/system/farm-domains.json', (req, res) => { cors(res); res.json(listFarmDomains(primaryRoot)) })
105
+ app.options('/system/outposts.json', (req, res) => { cors(res); res.sendStatus(204) })
106
+ app.get('/system/outposts.json', (req, res) => { cors(res); res.json({ unfurl: readOutposts(statusDir) }) })
284
107
  }