wiki-plugin-hitchhiker 0.3.0 → 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.0",
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,49 +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 (the extractor libs do their own fetch and may follow
21
- // redirects to internal hosts) and tighten image caps.
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.
22
18
 
23
- import dns from 'node:dns/promises'
24
- import net from 'node:net'
25
19
  import fs from 'node:fs'
26
20
  import path from 'node:path'
27
21
  import os from 'node:os'
28
22
 
29
- // The two extractor libraries are imported on first use, not at start-up.
30
- // Together they load about 14 MB of heap — eighty per cent of the memory of
31
- // every plugin server on the farm combined — and this server is resident in
32
- // the shared process on every site of the host, whether or not anything ever
33
- // asks for a link preview. Lazy import means a host that never unfurls never
34
- // pays. Each import is awaited once and cached, so the second request costs
35
- // nothing; a failed import is not cached, so a transient error does not stick.
36
- let ogsP = null
37
- const ogs = (...a) => (ogsP ||= import('open-graph-scraper').then(m => m.default, e => { ogsP = null; throw e })).then(f => f(...a))
38
- let oembedP = null
39
- const oembedExtract = (...a) =>
40
- (oembedP ||= import('@extractus/oembed-extractor').then(m => m.extract, e => { oembedP = null; throw e })).then(f => f(...a))
41
-
42
- const CACHE = new Map()
43
- const TTL = 1000 * 60 * 30 // 30 minutes
44
- const FETCH_MS = 8000
45
- const MAX_IMAGE_BYTES = 300 * 1024
46
-
47
23
  // ── farm-domain listing ("is this host ours?") ────────────────────────────────
48
24
  // primary farm root = 'local' (the *.localhost private farm); extra roots = 'public'
49
25
  // (the Nextcloud mirror). A directory is a wiki domain if it contains a pages/ dir.
@@ -86,166 +62,46 @@ const listFarmDomains = primaryRoot => {
86
62
  return out
87
63
  }
88
64
 
89
- // ── SSRF guard ────────────────────────────────────────────────────────────────
90
- const isPrivateIp = ip => {
91
- if (net.isIP(ip) === 4) {
92
- const [a, b] = ip.split('.').map(Number)
93
- return (
94
- a === 0 ||
95
- a === 127 ||
96
- a === 10 ||
97
- (a === 172 && b >= 16 && b <= 31) ||
98
- (a === 192 && b === 168) ||
99
- (a === 169 && b === 254) ||
100
- a >= 224
101
- )
102
- }
103
- const s = ip.toLowerCase()
104
- return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
105
- }
106
-
107
- const assertPublicUrl = async raw => {
108
- const u = new URL(raw) // throws on bad url
109
- if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('protocol not allowed')
110
- const host = u.hostname.toLowerCase()
111
- if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal'))
112
- throw new Error('internal host blocked')
113
- // Literal-IP hosts are range-checked directly; names are resolved first.
114
- const addrs = net.isIP(host) ? [{ address: host }] : await dns.lookup(host, { all: true })
115
- for (const { address } of addrs) if (isPrivateIp(address)) throw new Error('private address blocked')
116
- return u
117
- }
118
-
119
- // ── thumbnail → data URI (no client→provider request when the card renders) ───
120
- const inlineImage = async imgUrl => {
121
- if (!imgUrl) return ''
122
- try {
123
- await assertPublicUrl(imgUrl)
124
- const r = await fetch(imgUrl, { signal: AbortSignal.timeout(FETCH_MS), redirect: 'follow' })
125
- if (!r.ok) return ''
126
- const type = r.headers.get('content-type') || ''
127
- if (!/^image\//i.test(type)) return ''
128
- const len = Number(r.headers.get('content-length') || 0)
129
- if (len && len > MAX_IMAGE_BYTES) return ''
130
- const buf = Buffer.from(await r.arrayBuffer())
131
- if (buf.length > MAX_IMAGE_BYTES) return ''
132
- return `data:${type.split(';')[0]};base64,${buf.toString('base64')}`
133
- } catch {
134
- return ''
135
- }
136
- }
137
65
 
138
- // ── extractors ────────────────────────────────────────────────────────────────
139
- const fromOembed = async url => {
140
- try {
141
- const d = await oembedExtract(url, {}, { signal: AbortSignal.timeout(FETCH_MS) })
142
- if (!d) return null
143
- return {
144
- title: d.title || d.author_name || '',
145
- description: '',
146
- provider: d.provider_name || '',
147
- author: d.author_name || '',
148
- type: d.type || 'rich',
149
- thumb: d.thumbnail_url || '',
150
- source: 'oembed',
151
- }
152
- } catch {
153
- return null // not an oEmbed provider (or fetch failed) → fall back
154
- }
155
- }
156
-
157
- const fromOpenGraph = async url => {
158
- try {
159
- const r = await ogs({ url, fetchOptions: { signal: AbortSignal.timeout(FETCH_MS) } })
160
- if (!r || r.error || !r.result) return null
161
- const x = r.result
162
- const img =
163
- (Array.isArray(x.ogImage) && x.ogImage[0] && x.ogImage[0].url) ||
164
- (Array.isArray(x.twitterImage) && x.twitterImage[0] && x.twitterImage[0].url) ||
165
- ''
166
- return {
167
- title: x.ogTitle || x.twitterTitle || x.dcTitle || '',
168
- description: x.ogDescription || x.twitterDescription || '',
169
- provider: x.ogSiteName || '',
170
- author: x.author || '',
171
- type: x.ogType || 'website',
172
- thumb: img,
173
- favicon: x.favicon || '',
174
- 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
+ }
175
88
  }
176
- } catch {
177
- return null
178
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)
179
92
  }
180
93
 
181
- // ── route ─────────────────────────────────────────────────────────────────────
94
+ // ── routes ───────────────────────────────────────────────────────────────────
182
95
  export const startServer = ({ app, argv }) => {
183
- // argv.status = {farm}/{thisDomain}/status → primary farm root is two levels up.
184
96
  const primaryRoot = argv && argv.status ? path.dirname(path.dirname(argv.status)) : ''
185
- console.log('[wiki-plugin-hitchhiker] registering GET /system/unfurl.json + /system/farm-domains.json')
186
-
97
+ const statusDir = argv && argv.status ? argv.status : ''
98
+ console.log('[wiki-plugin-hitchhiker] registering GET /system/farm-domains.json + /system/outposts.json')
187
99
  const cors = res => {
188
100
  res.setHeader('Access-Control-Allow-Origin', '*')
189
101
  res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
190
102
  }
191
-
192
- app.options('/system/farm-domains.json', (req, res) => {
193
- cors(res)
194
- res.sendStatus(204)
195
- })
196
- app.get('/system/farm-domains.json', (req, res) => {
197
- cors(res)
198
- res.json(listFarmDomains(primaryRoot))
199
- })
200
-
201
- app.options('/system/unfurl.json', (req, res) => {
202
- cors(res)
203
- res.sendStatus(204)
204
- })
205
-
206
- app.get('/system/unfurl.json', async (req, res) => {
207
- cors(res)
208
- const url = String(req.query.url || '')
209
- let host = ''
210
- try {
211
- host = new URL(url).hostname
212
- } catch {}
213
-
214
- const hit = CACHE.get(url)
215
- if (hit && Date.now() - hit.t < TTL) return res.json(hit.v)
216
-
217
- let card
218
- try {
219
- await assertPublicUrl(url)
220
- const meta = (await fromOembed(url)) || (await fromOpenGraph(url)) || {}
221
- const image = await inlineImage(meta.thumb)
222
- card = {
223
- url,
224
- title: meta.title || host || url,
225
- description: meta.description || '',
226
- provider: meta.provider || host || '',
227
- author: meta.author || '',
228
- type: meta.type || 'link',
229
- image,
230
- favicon: meta.favicon || '',
231
- source: meta.source || 'fallback',
232
- }
233
- } catch (e) {
234
- // Graceful degradation (bad/blocked/offline URL) — always return a card.
235
- card = {
236
- url,
237
- title: host || url,
238
- description: '',
239
- provider: host,
240
- author: '',
241
- type: 'link',
242
- image: '',
243
- favicon: '',
244
- source: 'error',
245
- error: String((e && e.message) || e),
246
- }
247
- }
248
- CACHE.set(url, { t: Date.now(), v: card })
249
- res.json(card)
250
- })
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) }) })
251
107
  }