wiki-plugin-hitchhiker 0.2.0 → 0.3.1
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 +6 -3
- package/package.json +5 -2
- package/server/server.js +51 -7
package/README.md
CHANGED
|
@@ -12,8 +12,8 @@ fallback), unless a site has authored its own page of that slug. Currently:
|
|
|
12
12
|
`tools`, `changes-to-this-site` (copied from hitchhikers.earth, journals stripped),
|
|
13
13
|
and `about-hitchhiker-plugin`.
|
|
14
14
|
|
|
15
|
-
This plugin defines **no authorable item type**
|
|
16
|
-
pages and the served client script below.
|
|
15
|
+
This plugin defines **no authorable item type** — its `factory.json` exists only to
|
|
16
|
+
declare the bundled pages — its value is the pages and the served client script below.
|
|
17
17
|
|
|
18
18
|
## 2. Client UX override (`client/wiki-ux.js`)
|
|
19
19
|
Served at `/plugins/hitchhiker/wiki-ux.js` and loaded globally on every page by the
|
|
@@ -40,7 +40,10 @@ surface. It installs:
|
|
|
40
40
|
a loud pulsing highlight; on drop it opens a ghost **preview card** (see the server below).
|
|
41
41
|
|
|
42
42
|
## 3. Server component (`server/server.js`, ESM)
|
|
43
|
-
`GET /system/unfurl.json?url=…` → a normalized **static** link-preview card
|
|
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.
|
|
44
47
|
`{ url, title, description, provider, author, type, image, favicon, source }`.
|
|
45
48
|
- oEmbed metadata first (`@extractus/oembed-extractor`), OpenGraph/Twitter/JSON-LD fallback
|
|
46
49
|
(`open-graph-scraper`). Metadata only — never the oEmbed HTML embed (no live third-party
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-plugin-hitchhiker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -29,5 +29,8 @@
|
|
|
29
29
|
"version": 1,
|
|
30
30
|
"spec": "api/openapi.json"
|
|
31
31
|
}
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test test/*.test.mjs"
|
|
32
35
|
}
|
|
33
|
-
}
|
|
36
|
+
}
|
package/server/server.js
CHANGED
|
@@ -17,16 +17,28 @@
|
|
|
17
17
|
// the client: on any failure it returns a minimal card so a drop always works.
|
|
18
18
|
//
|
|
19
19
|
// v1 scope: private farm (owner-only). Before public use, harden SSRF against
|
|
20
|
-
// redirect-following
|
|
21
|
-
//
|
|
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.
|
|
22
23
|
|
|
23
24
|
import dns from 'node:dns/promises'
|
|
24
25
|
import net from 'node:net'
|
|
25
26
|
import fs from 'node:fs'
|
|
26
27
|
import path from 'node:path'
|
|
27
28
|
import os from 'node:os'
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
|
|
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))
|
|
30
42
|
|
|
31
43
|
const CACHE = new Map()
|
|
32
44
|
const TTL = 1000 * 60 * 30 // 30 minutes
|
|
@@ -105,12 +117,34 @@ const assertPublicUrl = async raw => {
|
|
|
105
117
|
return u
|
|
106
118
|
}
|
|
107
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
|
+
|
|
108
143
|
// ── thumbnail → data URI (no client→provider request when the card renders) ───
|
|
109
144
|
const inlineImage = async imgUrl => {
|
|
110
145
|
if (!imgUrl) return ''
|
|
111
146
|
try {
|
|
112
|
-
await
|
|
113
|
-
const r = await fetch(imgUrl, { signal: AbortSignal.timeout(FETCH_MS), redirect: 'follow' })
|
|
147
|
+
const r = await safeFetch(imgUrl, { signal: AbortSignal.timeout(FETCH_MS) })
|
|
114
148
|
if (!r.ok) return ''
|
|
115
149
|
const type = r.headers.get('content-type') || ''
|
|
116
150
|
if (!/^image\//i.test(type)) return ''
|
|
@@ -127,6 +161,12 @@ const inlineImage = async imgUrl => {
|
|
|
127
161
|
// ── extractors ────────────────────────────────────────────────────────────────
|
|
128
162
|
const fromOembed = async url => {
|
|
129
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.
|
|
130
170
|
const d = await oembedExtract(url, {}, { signal: AbortSignal.timeout(FETCH_MS) })
|
|
131
171
|
if (!d) return null
|
|
132
172
|
return {
|
|
@@ -145,7 +185,11 @@ const fromOembed = async url => {
|
|
|
145
185
|
|
|
146
186
|
const fromOpenGraph = async url => {
|
|
147
187
|
try {
|
|
148
|
-
|
|
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' } })
|
|
149
193
|
if (!r || r.error || !r.result) return null
|
|
150
194
|
const x = r.result
|
|
151
195
|
const img =
|