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 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** (no `factory.json`) — its value is the
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.2.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 (the extractor libs do their own fetch and may follow
21
- // redirects to internal hosts) and tighten image caps.
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
- import ogs from 'open-graph-scraper'
29
- import { extract as oembedExtract } from '@extractus/oembed-extractor'
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 assertPublicUrl(imgUrl)
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
- const r = await ogs({ url, fetchOptions: { signal: AbortSignal.timeout(FETCH_MS) } })
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 =