wiki-plugin-hitchhiker 0.3.1 → 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 -222
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,50 +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
|
-
//
|
|
22
|
-
//
|
|
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.
|
|
23
18
|
|
|
24
|
-
import dns from 'node:dns/promises'
|
|
25
|
-
import net from 'node:net'
|
|
26
19
|
import fs from 'node:fs'
|
|
27
20
|
import path from 'node:path'
|
|
28
21
|
import os from 'node:os'
|
|
29
22
|
|
|
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))
|
|
42
|
-
|
|
43
|
-
const CACHE = new Map()
|
|
44
|
-
const TTL = 1000 * 60 * 30 // 30 minutes
|
|
45
|
-
const FETCH_MS = 8000
|
|
46
|
-
const MAX_IMAGE_BYTES = 300 * 1024
|
|
47
|
-
|
|
48
23
|
// ── farm-domain listing ("is this host ours?") ────────────────────────────────
|
|
49
24
|
// primary farm root = 'local' (the *.localhost private farm); extra roots = 'public'
|
|
50
25
|
// (the Nextcloud mirror). A directory is a wiki domain if it contains a pages/ dir.
|
|
@@ -87,198 +62,46 @@ const listFarmDomains = primaryRoot => {
|
|
|
87
62
|
return out
|
|
88
63
|
}
|
|
89
64
|
|
|
90
|
-
// ── SSRF guard ────────────────────────────────────────────────────────────────
|
|
91
|
-
const isPrivateIp = ip => {
|
|
92
|
-
if (net.isIP(ip) === 4) {
|
|
93
|
-
const [a, b] = ip.split('.').map(Number)
|
|
94
|
-
return (
|
|
95
|
-
a === 0 ||
|
|
96
|
-
a === 127 ||
|
|
97
|
-
a === 10 ||
|
|
98
|
-
(a === 172 && b >= 16 && b <= 31) ||
|
|
99
|
-
(a === 192 && b === 168) ||
|
|
100
|
-
(a === 169 && b === 254) ||
|
|
101
|
-
a >= 224
|
|
102
|
-
)
|
|
103
|
-
}
|
|
104
|
-
const s = ip.toLowerCase()
|
|
105
|
-
return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
|
|
106
|
-
}
|
|
107
65
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
143
|
-
// ── thumbnail → data URI (no client→provider request when the card renders) ───
|
|
144
|
-
const inlineImage = async imgUrl => {
|
|
145
|
-
if (!imgUrl) return ''
|
|
146
|
-
try {
|
|
147
|
-
const r = await safeFetch(imgUrl, { signal: AbortSignal.timeout(FETCH_MS) })
|
|
148
|
-
if (!r.ok) return ''
|
|
149
|
-
const type = r.headers.get('content-type') || ''
|
|
150
|
-
if (!/^image\//i.test(type)) return ''
|
|
151
|
-
const len = Number(r.headers.get('content-length') || 0)
|
|
152
|
-
if (len && len > MAX_IMAGE_BYTES) return ''
|
|
153
|
-
const buf = Buffer.from(await r.arrayBuffer())
|
|
154
|
-
if (buf.length > MAX_IMAGE_BYTES) return ''
|
|
155
|
-
return `data:${type.split(';')[0]};base64,${buf.toString('base64')}`
|
|
156
|
-
} catch {
|
|
157
|
-
return ''
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ── extractors ────────────────────────────────────────────────────────────────
|
|
162
|
-
const fromOembed = async url => {
|
|
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.
|
|
170
|
-
const d = await oembedExtract(url, {}, { signal: AbortSignal.timeout(FETCH_MS) })
|
|
171
|
-
if (!d) return null
|
|
172
|
-
return {
|
|
173
|
-
title: d.title || d.author_name || '',
|
|
174
|
-
description: '',
|
|
175
|
-
provider: d.provider_name || '',
|
|
176
|
-
author: d.author_name || '',
|
|
177
|
-
type: d.type || 'rich',
|
|
178
|
-
thumb: d.thumbnail_url || '',
|
|
179
|
-
source: 'oembed',
|
|
180
|
-
}
|
|
181
|
-
} catch {
|
|
182
|
-
return null // not an oEmbed provider (or fetch failed) → fall back
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const fromOpenGraph = async url => {
|
|
187
|
-
try {
|
|
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' } })
|
|
193
|
-
if (!r || r.error || !r.result) return null
|
|
194
|
-
const x = r.result
|
|
195
|
-
const img =
|
|
196
|
-
(Array.isArray(x.ogImage) && x.ogImage[0] && x.ogImage[0].url) ||
|
|
197
|
-
(Array.isArray(x.twitterImage) && x.twitterImage[0] && x.twitterImage[0].url) ||
|
|
198
|
-
''
|
|
199
|
-
return {
|
|
200
|
-
title: x.ogTitle || x.twitterTitle || x.dcTitle || '',
|
|
201
|
-
description: x.ogDescription || x.twitterDescription || '',
|
|
202
|
-
provider: x.ogSiteName || '',
|
|
203
|
-
author: x.author || '',
|
|
204
|
-
type: x.ogType || 'website',
|
|
205
|
-
thumb: img,
|
|
206
|
-
favicon: x.favicon || '',
|
|
207
|
-
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
|
+
}
|
|
208
88
|
}
|
|
209
|
-
} catch {
|
|
210
|
-
return null
|
|
211
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)
|
|
212
92
|
}
|
|
213
93
|
|
|
214
|
-
// ──
|
|
94
|
+
// ── routes ───────────────────────────────────────────────────────────────────
|
|
215
95
|
export const startServer = ({ app, argv }) => {
|
|
216
|
-
// argv.status = {farm}/{thisDomain}/status → primary farm root is two levels up.
|
|
217
96
|
const primaryRoot = argv && argv.status ? path.dirname(path.dirname(argv.status)) : ''
|
|
218
|
-
|
|
219
|
-
|
|
97
|
+
const statusDir = argv && argv.status ? argv.status : ''
|
|
98
|
+
console.log('[wiki-plugin-hitchhiker] registering GET /system/farm-domains.json + /system/outposts.json')
|
|
220
99
|
const cors = res => {
|
|
221
100
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
222
101
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
|
223
102
|
}
|
|
224
|
-
|
|
225
|
-
app.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
})
|
|
229
|
-
app.get('/system/farm-domains.json', (req, res) => {
|
|
230
|
-
cors(res)
|
|
231
|
-
res.json(listFarmDomains(primaryRoot))
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
app.options('/system/unfurl.json', (req, res) => {
|
|
235
|
-
cors(res)
|
|
236
|
-
res.sendStatus(204)
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
app.get('/system/unfurl.json', async (req, res) => {
|
|
240
|
-
cors(res)
|
|
241
|
-
const url = String(req.query.url || '')
|
|
242
|
-
let host = ''
|
|
243
|
-
try {
|
|
244
|
-
host = new URL(url).hostname
|
|
245
|
-
} catch {}
|
|
246
|
-
|
|
247
|
-
const hit = CACHE.get(url)
|
|
248
|
-
if (hit && Date.now() - hit.t < TTL) return res.json(hit.v)
|
|
249
|
-
|
|
250
|
-
let card
|
|
251
|
-
try {
|
|
252
|
-
await assertPublicUrl(url)
|
|
253
|
-
const meta = (await fromOembed(url)) || (await fromOpenGraph(url)) || {}
|
|
254
|
-
const image = await inlineImage(meta.thumb)
|
|
255
|
-
card = {
|
|
256
|
-
url,
|
|
257
|
-
title: meta.title || host || url,
|
|
258
|
-
description: meta.description || '',
|
|
259
|
-
provider: meta.provider || host || '',
|
|
260
|
-
author: meta.author || '',
|
|
261
|
-
type: meta.type || 'link',
|
|
262
|
-
image,
|
|
263
|
-
favicon: meta.favicon || '',
|
|
264
|
-
source: meta.source || 'fallback',
|
|
265
|
-
}
|
|
266
|
-
} catch (e) {
|
|
267
|
-
// Graceful degradation (bad/blocked/offline URL) — always return a card.
|
|
268
|
-
card = {
|
|
269
|
-
url,
|
|
270
|
-
title: host || url,
|
|
271
|
-
description: '',
|
|
272
|
-
provider: host,
|
|
273
|
-
author: '',
|
|
274
|
-
type: 'link',
|
|
275
|
-
image: '',
|
|
276
|
-
favicon: '',
|
|
277
|
-
source: 'error',
|
|
278
|
-
error: String((e && e.message) || e),
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
CACHE.set(url, { t: Date.now(), v: card })
|
|
282
|
-
res.json(card)
|
|
283
|
-
})
|
|
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) }) })
|
|
284
107
|
}
|