wiki-plugin-hitchhiker 0.2.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.
@@ -0,0 +1,240 @@
1
+ // wiki-plugin-hitchhiker — server component (ESM)
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.
7
+ //
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.
18
+ //
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.
22
+
23
+ import dns from 'node:dns/promises'
24
+ import net from 'node:net'
25
+ import fs from 'node:fs'
26
+ import path from 'node:path'
27
+ import os from 'node:os'
28
+ import ogs from 'open-graph-scraper'
29
+ import { extract as oembedExtract } from '@extractus/oembed-extractor'
30
+
31
+ const CACHE = new Map()
32
+ const TTL = 1000 * 60 * 30 // 30 minutes
33
+ const FETCH_MS = 8000
34
+ const MAX_IMAGE_BYTES = 300 * 1024
35
+
36
+ // ── farm-domain listing ("is this host ours?") ────────────────────────────────
37
+ // primary farm root = 'local' (the *.localhost private farm); extra roots = 'public'
38
+ // (the Nextcloud mirror). A directory is a wiki domain if it contains a pages/ dir.
39
+ const EXTRA_FARMS = (process.env.WIKI_EXTRA_FARMS || '').split(':').filter(Boolean)
40
+ let _farmCache = null
41
+ const listFarmDomains = primaryRoot => {
42
+ if (_farmCache && Date.now() - _farmCache.t < 60000) return _farmCache.v
43
+ const extra = EXTRA_FARMS.length
44
+ ? EXTRA_FARMS
45
+ : [path.join(os.homedir(), 'Nextcloud', 'fedwiki')].filter(p => {
46
+ try {
47
+ return fs.existsSync(p)
48
+ } catch {
49
+ return false
50
+ }
51
+ })
52
+ const farms = [[primaryRoot, 'local'], ...extra.map(f => [f, 'public'])]
53
+ const out = { local: [], public: [] }
54
+ const seen = new Set()
55
+ for (const [farm, kind] of farms) {
56
+ if (!farm) continue
57
+ let entries
58
+ try {
59
+ entries = fs.readdirSync(farm, { withFileTypes: true })
60
+ } catch {
61
+ continue
62
+ }
63
+ for (const ent of entries) {
64
+ if (!ent.isDirectory() || seen.has(ent.name)) continue
65
+ try {
66
+ if (!fs.existsSync(path.join(farm, ent.name, 'pages'))) continue
67
+ } catch {
68
+ continue
69
+ }
70
+ seen.add(ent.name)
71
+ out[kind].push(ent.name)
72
+ }
73
+ }
74
+ _farmCache = { t: Date.now(), v: out }
75
+ return out
76
+ }
77
+
78
+ // ── SSRF guard ────────────────────────────────────────────────────────────────
79
+ const isPrivateIp = ip => {
80
+ if (net.isIP(ip) === 4) {
81
+ const [a, b] = ip.split('.').map(Number)
82
+ return (
83
+ a === 0 ||
84
+ a === 127 ||
85
+ a === 10 ||
86
+ (a === 172 && b >= 16 && b <= 31) ||
87
+ (a === 192 && b === 168) ||
88
+ (a === 169 && b === 254) ||
89
+ a >= 224
90
+ )
91
+ }
92
+ const s = ip.toLowerCase()
93
+ return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
94
+ }
95
+
96
+ const assertPublicUrl = async raw => {
97
+ const u = new URL(raw) // throws on bad url
98
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('protocol not allowed')
99
+ const host = u.hostname.toLowerCase()
100
+ if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal'))
101
+ throw new Error('internal host blocked')
102
+ // Literal-IP hosts are range-checked directly; names are resolved first.
103
+ const addrs = net.isIP(host) ? [{ address: host }] : await dns.lookup(host, { all: true })
104
+ for (const { address } of addrs) if (isPrivateIp(address)) throw new Error('private address blocked')
105
+ return u
106
+ }
107
+
108
+ // ── thumbnail → data URI (no client→provider request when the card renders) ───
109
+ const inlineImage = async imgUrl => {
110
+ if (!imgUrl) return ''
111
+ try {
112
+ await assertPublicUrl(imgUrl)
113
+ const r = await fetch(imgUrl, { signal: AbortSignal.timeout(FETCH_MS), redirect: 'follow' })
114
+ if (!r.ok) return ''
115
+ const type = r.headers.get('content-type') || ''
116
+ if (!/^image\//i.test(type)) return ''
117
+ const len = Number(r.headers.get('content-length') || 0)
118
+ if (len && len > MAX_IMAGE_BYTES) return ''
119
+ const buf = Buffer.from(await r.arrayBuffer())
120
+ if (buf.length > MAX_IMAGE_BYTES) return ''
121
+ return `data:${type.split(';')[0]};base64,${buf.toString('base64')}`
122
+ } catch {
123
+ return ''
124
+ }
125
+ }
126
+
127
+ // ── extractors ────────────────────────────────────────────────────────────────
128
+ const fromOembed = async url => {
129
+ try {
130
+ const d = await oembedExtract(url, {}, { signal: AbortSignal.timeout(FETCH_MS) })
131
+ if (!d) return null
132
+ return {
133
+ title: d.title || d.author_name || '',
134
+ description: '',
135
+ provider: d.provider_name || '',
136
+ author: d.author_name || '',
137
+ type: d.type || 'rich',
138
+ thumb: d.thumbnail_url || '',
139
+ source: 'oembed',
140
+ }
141
+ } catch {
142
+ return null // not an oEmbed provider (or fetch failed) → fall back
143
+ }
144
+ }
145
+
146
+ const fromOpenGraph = async url => {
147
+ try {
148
+ const r = await ogs({ url, fetchOptions: { signal: AbortSignal.timeout(FETCH_MS) } })
149
+ if (!r || r.error || !r.result) return null
150
+ const x = r.result
151
+ const img =
152
+ (Array.isArray(x.ogImage) && x.ogImage[0] && x.ogImage[0].url) ||
153
+ (Array.isArray(x.twitterImage) && x.twitterImage[0] && x.twitterImage[0].url) ||
154
+ ''
155
+ return {
156
+ title: x.ogTitle || x.twitterTitle || x.dcTitle || '',
157
+ description: x.ogDescription || x.twitterDescription || '',
158
+ provider: x.ogSiteName || '',
159
+ author: x.author || '',
160
+ type: x.ogType || 'website',
161
+ thumb: img,
162
+ favicon: x.favicon || '',
163
+ source: 'opengraph',
164
+ }
165
+ } catch {
166
+ return null
167
+ }
168
+ }
169
+
170
+ // ── route ─────────────────────────────────────────────────────────────────────
171
+ export const startServer = ({ app, argv }) => {
172
+ // argv.status = {farm}/{thisDomain}/status → primary farm root is two levels up.
173
+ const primaryRoot = argv && argv.status ? path.dirname(path.dirname(argv.status)) : ''
174
+ console.log('[wiki-plugin-hitchhiker] registering GET /system/unfurl.json + /system/farm-domains.json')
175
+
176
+ const cors = res => {
177
+ res.setHeader('Access-Control-Allow-Origin', '*')
178
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
179
+ }
180
+
181
+ app.options('/system/farm-domains.json', (req, res) => {
182
+ cors(res)
183
+ res.sendStatus(204)
184
+ })
185
+ app.get('/system/farm-domains.json', (req, res) => {
186
+ cors(res)
187
+ res.json(listFarmDomains(primaryRoot))
188
+ })
189
+
190
+ app.options('/system/unfurl.json', (req, res) => {
191
+ cors(res)
192
+ res.sendStatus(204)
193
+ })
194
+
195
+ app.get('/system/unfurl.json', async (req, res) => {
196
+ cors(res)
197
+ const url = String(req.query.url || '')
198
+ let host = ''
199
+ try {
200
+ host = new URL(url).hostname
201
+ } catch {}
202
+
203
+ const hit = CACHE.get(url)
204
+ if (hit && Date.now() - hit.t < TTL) return res.json(hit.v)
205
+
206
+ let card
207
+ try {
208
+ await assertPublicUrl(url)
209
+ const meta = (await fromOembed(url)) || (await fromOpenGraph(url)) || {}
210
+ const image = await inlineImage(meta.thumb)
211
+ card = {
212
+ url,
213
+ title: meta.title || host || url,
214
+ description: meta.description || '',
215
+ provider: meta.provider || host || '',
216
+ author: meta.author || '',
217
+ type: meta.type || 'link',
218
+ image,
219
+ favicon: meta.favicon || '',
220
+ source: meta.source || 'fallback',
221
+ }
222
+ } catch (e) {
223
+ // Graceful degradation (bad/blocked/offline URL) — always return a card.
224
+ card = {
225
+ url,
226
+ title: host || url,
227
+ description: '',
228
+ provider: host,
229
+ author: '',
230
+ type: 'link',
231
+ image: '',
232
+ favicon: '',
233
+ source: 'error',
234
+ error: String((e && e.message) || e),
235
+ }
236
+ }
237
+ CACHE.set(url, { t: Date.now(), v: card })
238
+ res.json(card)
239
+ })
240
+ }