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.
- package/README.md +84 -0
- package/api/openapi.json +125 -0
- package/client/fish.js +101 -0
- package/client/hitchhiker.js +128 -0
- package/client/picker.html +140 -0
- package/client/wiki-ux.js +1770 -0
- package/factory.json +9 -0
- package/package.json +33 -0
- package/pages/about-hitchhiker-plugin +106 -0
- package/pages/changes-to-this-site +37 -0
- package/pages/login-tool +188 -0
- package/pages/tools +159 -0
- package/server/server.js +240 -0
package/server/server.js
ADDED
|
@@ -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
|
+
}
|