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 +13 -17
- package/client/wiki-ux.js +37 -7
- package/package.json +2 -6
- package/server/server.js +45 -189
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/
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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.
|
|
4
|
-
"description": "House UX + default-pages plugin for the hitchhiker farms: ships default pages (Tools, Changes to this Site)
|
|
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
|
|
1
|
+
// wiki-plugin-hitchhiker — server component. ESM.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// —
|
|
5
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
// ──
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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.
|
|
193
|
-
|
|
194
|
-
|
|
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
|
}
|