@huakirax/dsh-plugin-pocket 0.0.0-stage → 0.1.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.
Files changed (44) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +237 -2
  3. package/bin/pocket.js +31 -0
  4. package/cordis.patch.yml +6 -0
  5. package/index.js +360 -0
  6. package/lib/client.js +2453 -0
  7. package/package.json +91 -4
  8. package/src/advertise-host.js +69 -0
  9. package/src/approval-hook.js +50 -0
  10. package/src/client/ConfirmDialog.tsx +80 -0
  11. package/src/client/HostUiLauncher.tsx +101 -0
  12. package/src/client/ManagePanel.tsx +218 -0
  13. package/src/client/PhoneIcon.tsx +48 -0
  14. package/src/client/PocketSettings.tsx +13 -0
  15. package/src/client/api.ts +97 -0
  16. package/src/client/completion-sync.js +289 -0
  17. package/src/client/host-ui-styles.ts +62 -0
  18. package/src/client/index.ts +47 -0
  19. package/src/client/locales.ts +75 -0
  20. package/src/client/nav-icon.ts +31 -0
  21. package/src/client/qr.ts +15 -0
  22. package/src/client/styles.ts +76 -0
  23. package/src/completion-route.js +87 -0
  24. package/src/completion-tracker.js +95 -0
  25. package/src/devices.js +60 -0
  26. package/src/extra-ops.js +604 -0
  27. package/src/host-ui.js +466 -0
  28. package/src/instance-id.js +33 -0
  29. package/src/iroh.js +260 -0
  30. package/src/manage.js +122 -0
  31. package/src/mdns-advertise.js +75 -0
  32. package/src/office-preview.js +129 -0
  33. package/src/pairing-uri.js +42 -0
  34. package/src/pairing.js +34 -0
  35. package/src/parity-ops.js +250 -0
  36. package/src/server.js +761 -0
  37. package/src/settings-ops.js +387 -0
  38. package/src/task-events.js +77 -0
  39. package/src/task-fallback.js +177 -0
  40. package/src/token.js +18 -0
  41. package/src/transport-pref.js +47 -0
  42. package/src/trust.js +25 -0
  43. package/src/user-question-hook.js +93 -0
  44. package/src/web-route.js +116 -0
package/src/host-ui.js ADDED
@@ -0,0 +1,466 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { request as httpRequest } from 'node:http'
3
+ import { request as httpsRequest } from 'node:https'
4
+ import { networkInterfaces } from 'node:os'
5
+ import { createGzip } from 'node:zlib'
6
+ import { WebSocket, WebSocketServer } from 'ws'
7
+
8
+ export const UI_COOKIE = 'dsh-pocket-web'
9
+ export const UI_TICKET_MS = 60_000
10
+ export const UI_SESSION_MS = 15 * 60_000
11
+ const MAX_GRANTS = 128
12
+ const MAX_HTML_BYTES = 8 * 1024 * 1024
13
+ const MAX_ACCOUNT_SCRIPT_BYTES = 16 * 1024 * 1024
14
+ const MAX_ARTWORK_BYTES = 32 * 1024 * 1024
15
+ const SAFE_RESPONSE_HEADERS = ['content-type', 'content-disposition', 'content-range', 'accept-ranges']
16
+ const SAFE_REQUEST_HEADERS = ['accept', 'accept-language', 'content-type', 'content-length', 'range', 'last-event-id']
17
+ const RECOVERY_KEYS = ['backoffBaseMs', 'backoffFactor', 'backoffMaxMs', 'generationReadyWarnMs', 'generationReadyTimeoutMs']
18
+ const RESERVED_UI_NAMESPACES = new Set(['pocket', 'pocket-ui', 'open-in-app', 'api', 'assets', 'plugins', 'discovery'])
19
+
20
+ /** Compress document/module bytes without buffering streams or changing byte ranges. */
21
+ function responseStream(req, res, response) {
22
+ const type = String(response.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase()
23
+ if (req.method === 'HEAD' || response.statusCode !== 200 || response.headers['content-encoding']
24
+ || !/^(?:text\/(?:html|css|javascript)|application\/(?:javascript|json|xhtml\+xml))$/u.test(type)) return res
25
+ res.setHeader('vary', 'Accept-Encoding')
26
+ const acceptsGzip = String(req.headers['accept-encoding'] ?? '').split(',').some((entry) => {
27
+ const [name, ...parameters] = entry.trim().toLowerCase().split(';').map((part) => part.trim())
28
+ const quality = parameters.find((part) => part.startsWith('q='))
29
+ return name === 'gzip' && (quality === undefined || Number(quality.slice(2)) > 0)
30
+ })
31
+ if (!acceptsGzip) return res
32
+ res.setHeader('content-encoding', 'gzip')
33
+ const stream = createGzip()
34
+ stream.on('error', () => res.destroy())
35
+ res.once('close', () => stream.destroy())
36
+ stream.pipe(res)
37
+ return stream
38
+ }
39
+
40
+ function unavailable(message = 'Host UI unavailable') {
41
+ return Object.assign(new Error(message), { code: 'ui/unavailable' })
42
+ }
43
+
44
+ /** The caller supplies services, never a URL from a device. TLS verification stays enabled. */
45
+ function hostOrigin(webServer) {
46
+ const { protocol, port } = webServer
47
+ if (!['http:', 'https:'].includes(protocol) || !Number.isInteger(port) || port < 1 || port > 65535) throw unavailable()
48
+ let host = webServer.host
49
+ if (host === '0.0.0.0') host = '127.0.0.1'
50
+ if (host === '::' || host === '[::]') host = '::1'
51
+ if (host?.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1)
52
+ const local = new Set(['localhost', '127.0.0.1', '::1'])
53
+ for (const addresses of Object.values(networkInterfaces())) for (const address of addresses ?? []) local.add(address.address)
54
+ if (!local.has(host)) throw unavailable('Host UI requires a local Host listener')
55
+ return `${protocol}//${host.includes(':') ? `[${host}]` : host}:${port}`
56
+ }
57
+
58
+ /** Reject normalization tricks before URL parsing can erase their evidence. */
59
+ function requestPath(raw) {
60
+ if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//') || /[\\#\p{Cc}\s]/u.test(raw)) return null
61
+ const path = raw.split('?')[0]
62
+ if (/%(?:2e|2f|5c|25|00)/iu.test(path) || path.includes('//') || path.split('/').some((part) => part === '.' || part === '..')) return null
63
+ try {
64
+ const decoded = decodeURIComponent(path)
65
+ if (/[\\\p{Cc}\s]/u.test(decoded)) return null
66
+ const url = new URL(raw, 'http://pocket.invalid')
67
+ for (const name of url.searchParams.keys()) if (/token|authorization|cookie|ticket/iu.test(name)) return null
68
+ return { path: decoded, rawPath: path, search: url.search }
69
+ } catch { return null }
70
+ }
71
+
72
+ function allowedPath(path) {
73
+ return ['/', '/index.html', '/favicon.ico', '/favicon.svg', '/favicon-dark.svg', '/manifest.webmanifest'].includes(path)
74
+ || ['/assets/', '/plugins/', '/api/'].some((prefix) => path.startsWith(prefix))
75
+ || path === '/api'
76
+ }
77
+
78
+ function pluginNamespaces(paths) {
79
+ if (!Array.isArray(paths)) throw new TypeError('pluginUiPaths must be an array')
80
+ for (const path of paths) {
81
+ if (typeof path !== 'string' || !/^\/[a-z0-9][a-z0-9_-]*$/u.test(path) || RESERVED_UI_NAMESPACES.has(path.slice(1))) {
82
+ throw new TypeError('pluginUiPaths entries must be non-reserved root namespaces (for example /my-plugin)')
83
+ }
84
+ }
85
+ return [...new Set(paths)]
86
+ }
87
+
88
+ /** Strict authority syntax: no userinfo, duplicate Host, or forwarded-host trust. */
89
+ export function uiRequestOrigin(req) {
90
+ const authority = req.headers.host
91
+ if (typeof authority !== 'string' || !authority || /[\s/@\\?#,]/u.test(authority)) return null
92
+ try {
93
+ const url = new URL(`${req.socket.encrypted ? 'https:' : 'http:'}//${authority}`)
94
+ if (!/^(?:[a-z0-9.-]+|\[[a-f0-9:.]+\])$/iu.test(url.hostname)) return null
95
+ return url.host.toLowerCase() === authority.toLowerCase() ? url.origin : null
96
+ } catch { return null }
97
+ }
98
+
99
+ function sameSite(req, origin, requireOrigin = false) {
100
+ const incoming = req.headers.origin
101
+ if (incoming !== undefined && incoming !== origin) return false
102
+ if (requireOrigin && incoming !== origin) return false
103
+ const site = req.headers['sec-fetch-site']
104
+ return site === undefined || site === 'none' || site === 'same-origin'
105
+ }
106
+
107
+ function headers(res) {
108
+ res.setHeader('cache-control', 'no-store')
109
+ res.setHeader('referrer-policy', 'no-referrer')
110
+ res.setHeader('x-content-type-options', 'nosniff')
111
+ res.setHeader('x-frame-options', 'DENY')
112
+ }
113
+ function reject(res, status = 403) {
114
+ if (res.destroyed) return
115
+ if (res.headersSent) { res.destroy(); return }
116
+ headers(res)
117
+ res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' }).end(status === 401 ? 'UI session required' : 'UI request denied')
118
+ }
119
+ function rejectUpgrade(socket, status = 403) {
120
+ socket.end(`HTTP/1.1 ${status} Denied\r\nConnection: close\r\nCache-Control: no-store\r\nContent-Length: 0\r\n\r\n`)
121
+ }
122
+
123
+ /** Keep the official module graph; upstream credentials and transport endpoints stay server-side. */
124
+ function browserDocument(html, secrets) {
125
+ const applicationPreloads = new Set()
126
+ let sanitized = html.replace(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/giu, (tag, attributes, source) => {
127
+ if (/\bsrc\s*=/iu.test(attributes)) return tag
128
+ if (/__DSH_TRANSPORT__|__DSH_NATIVE__|__DSH_DESKTOP__|\b(?:dshDesktop|dshPlatform|dshNative)\b/u.test(source)) return ''
129
+ const bootAssignment = /^\s*(?:globalThis|window)(?:\["__DSH_BOOT__"\]|\.__DSH_BOOT__)\s*=\s*(\{[\s\S]*\})\s*;?\s*$/u.exec(source)
130
+ if (bootAssignment) {
131
+ const graph = JSON.parse(bootAssignment[1])
132
+ if (Array.isArray(graph.entries) && Array.isArray(graph.batches)) {
133
+ const entries = new Map(graph.entries.map(entry => [entry.id, entry]))
134
+ graph.batches = graph.batches.flatMap((batch) => {
135
+ if (batch.phase !== 'application') return [batch]
136
+ applicationPreloads.add(batch.url.replace(/^\.\//u, ''))
137
+ return batch.entries.map((id) => {
138
+ const entry = entries.get(id)
139
+ if (!entry) throw unavailable('Host UI module graph is incomplete')
140
+ return { ...batch, url: entry.url, rev: entry.rev, entries: [id] }
141
+ })
142
+ })
143
+ return `<script>globalThis.__DSH_BOOT__=${JSON.stringify(graph).replace(/</gu, '\\u003c')}</script>`
144
+ }
145
+ }
146
+ if (!source.includes('__DSH_CONNECTION_RECOVERY__')) return tag
147
+ // Official renderIndex emits one JSON global per script. Keep only numeric timing fields.
148
+ const assignment = /^(?:\s*)(?:globalThis|window)(?:\["__DSH_CONNECTION_RECOVERY__"\]|\.__DSH_CONNECTION_RECOVERY__)\s*=\s*(\{[\s\S]*\})\s*;?\s*$/u.exec(source)
149
+ const safe = {}
150
+ if (assignment) {
151
+ try {
152
+ const recovery = JSON.parse(assignment[1])
153
+ for (const key of RECOVERY_KEYS) if (Number.isFinite(recovery[key]) && recovery[key] > 0) safe[key] = recovery[key]
154
+ } catch { /* Unknown recovery format gets official browser defaults, not Host credentials. */ }
155
+ }
156
+ return `<script>globalThis.__DSH_CONNECTION_RECOVERY__=${JSON.stringify(safe)}</script>`
157
+ })
158
+ // Large combo preloads would still compete with the per-module boot requests.
159
+ sanitized = sanitized.replace(/<link\b[^>]*>/giu, (tag) => {
160
+ if (!/\brel=["']preload["']/iu.test(tag)) return tag
161
+ const href = /\bhref=["']([^"']*)["']/iu.exec(tag)?.[1].replaceAll('&amp;', '&').replace(/^\.\//u, '')
162
+ return applicationPreloads.has(href) ? '' : tag
163
+ })
164
+ // Fail closed if a future injection moves a process credential outside recovery.
165
+ for (const secret of secrets) {
166
+ if (secret && [secret, encodeURIComponent(secret), JSON.stringify(secret).slice(1, -1)].some((value) => sanitized.includes(value))) throw unavailable()
167
+ }
168
+ // Only authenticated, device-bound proxy responses reach this function. This
169
+ // shell already grants Host settings RPCs; use the official desktop transport
170
+ // capability instead of letting a LAN/Tailscale hostname disable those forms.
171
+ // Never inherit upstream hooks, URLs, cookies, or recovery ownership.
172
+ const head = /<head\b[^>]*>/iu
173
+ if (!head.test(sanitized)) throw unavailable()
174
+ return sanitized.replace(head, '$&<script>globalThis.__DSH_TRANSPORT__={ownsHost:true}</script>')
175
+ }
176
+
177
+ /** One live service scope. Grants belong to the authenticated client object, not its token. */
178
+ export function createHostUi({ webServer, connection, devices, isClientLive, pluginUiPaths = [], now = Date.now, ticketMs = UI_TICKET_MS, sessionMs = UI_SESSION_MS }) {
179
+ const pluginPaths = pluginNamespaces(pluginUiPaths)
180
+ const origin = hostOrigin(webServer)
181
+ const tickets = new Map()
182
+ const sessions = new Map()
183
+ const artwork = new Map()
184
+ let artworkBytes = 0
185
+ const mux = new WebSocketServer({ noServer: true, maxPayload: 32 * 1024 * 1024, perMessageDeflate: false })
186
+ mux.on('headers', (outgoing) => {
187
+ outgoing.push('Cache-Control: no-store', 'Referrer-Policy: no-referrer')
188
+ })
189
+ const backendAbort = new AbortController()
190
+ let disposed = false
191
+ let authentication
192
+ let cookie = ''
193
+ let secrets = []
194
+
195
+ const live = (client) => !disposed && isClientLive(client) && client.device
196
+ && (client.device.owner || devices.list().some((device) => device.id === client.device.id))
197
+ function remove(map, key) {
198
+ const grant = map.get(key)
199
+ if (!grant) return
200
+ map.delete(key)
201
+ clearTimeout(grant.timer)
202
+ for (const stop of grant.resources ?? []) stop()
203
+ grant.resources?.clear()
204
+ }
205
+ function revoke(client, sourceTicket) {
206
+ for (const [key, grant] of tickets) if (grant.client === client && (sourceTicket === undefined || key === sourceTicket)) remove(tickets, key)
207
+ for (const [key, grant] of sessions) if (grant.client === client && (sourceTicket === undefined || grant.sourceTicket === sourceTicket)) remove(sessions, key)
208
+ }
209
+ function reap() {
210
+ for (const map of [tickets, sessions]) for (const [key, grant] of map) {
211
+ if (grant.expires <= now() || !live(grant.client)) remove(map, key)
212
+ }
213
+ }
214
+ // Also catch store revocation by callers outside the bridge. Never authenticate() on asset requests.
215
+ const sweep = setInterval(reap, 1_000)
216
+ sweep.unref?.()
217
+
218
+ async function authenticate() {
219
+ if (disposed) throw unavailable()
220
+ if (cookie) return cookie
221
+ if (!authentication) authentication = (async () => {
222
+ const url = new URL(connection.authenticatedUrl(`${origin}/`))
223
+ if (url.origin !== origin || url.pathname !== '/' || url.username || url.password) throw unavailable()
224
+ const launchSecrets = [...url.searchParams.values()]
225
+ const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.any([backendAbort.signal, AbortSignal.timeout(10_000)]) })
226
+ const issued = response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie')].filter(Boolean)
227
+ await response.body?.cancel()
228
+ if (disposed || response.status !== 303 || issued.length !== 1) throw unavailable()
229
+ const value = issued[0].split(';')[0]
230
+ if (!/^[^=\s;]+=[^;\r\n]+$/u.test(value)) throw unavailable()
231
+ cookie = value
232
+ secrets = [...launchSecrets, value, value.slice(value.indexOf('=') + 1)]
233
+ return cookie
234
+ })().finally(() => { authentication = undefined })
235
+ return authentication
236
+ }
237
+
238
+ // Keep onboarding PNG/WebP pixels intact without making unused images block boot.
239
+ function accountArtwork(source) {
240
+ const prefix = /(\b(?:var|let|const)\s+onboarding_(?:welcome|recharge)[\w$]*_default(?:\$\d+)?\s*=\s*)(["'])data:image\/(png|webp);base64,/gu
241
+ const parts = []
242
+ let cursor = 0
243
+ for (const match of source.matchAll(prefix)) {
244
+ const start = match.index + match[0].length
245
+ const end = source.indexOf(match[2], start)
246
+ if (end < start + 65_536) continue
247
+ const encoded = source.slice(start, end)
248
+ if (/[^A-Za-z\d+/=]/u.test(encoded)) continue
249
+ const path = `/assets/pocket-artwork/${createHash('sha256').update(encoded).digest('hex')}.${match[3]}`
250
+ if (!artwork.has(path)) {
251
+ const bytes = Buffer.from(encoded, 'base64')
252
+ if (artworkBytes + bytes.length > MAX_ARTWORK_BYTES) continue
253
+ artwork.set(path, bytes)
254
+ artworkBytes += bytes.length
255
+ }
256
+ parts.push(source.slice(cursor, match.index), match[1], match[2], path, match[2])
257
+ cursor = end + 1
258
+ }
259
+ if (cursor === 0) return source
260
+ parts.push(source.slice(cursor))
261
+ return parts.join('')
262
+ }
263
+
264
+ function authorized(req) {
265
+ const requestOrigin = uiRequestOrigin(req)
266
+ if (!requestOrigin || !sameSite(req, requestOrigin)) return null
267
+ const values = new Set((req.headers.cookie ?? '').split(';').map((part) => part.trim())
268
+ .filter((part) => part.startsWith(`${UI_COOKIE}=`)).map((part) => part.slice(UI_COOKIE.length + 1)))
269
+ const matches = []
270
+ for (const key of values) {
271
+ const session = sessions.get(key)
272
+ if (!session) continue
273
+ if (session.expires <= now() || !live(session.client)) { remove(sessions, key); continue }
274
+ if (session.origin === requestOrigin) matches.push(session)
275
+ }
276
+ // Ignore unrelated path-scoped cookie pollution, but never guess between live sessions.
277
+ return matches.length === 1 ? matches[0] : null
278
+ }
279
+
280
+ async function exchange(req, res) {
281
+ const requestOrigin = uiRequestOrigin(req)
282
+ if (req.method !== 'POST' || !requestOrigin || !sameSite(req, requestOrigin)
283
+ || req.headers['content-type']?.split(';')[0].trim().toLowerCase() !== 'application/x-www-form-urlencoded') { reject(res); return }
284
+ let body = ''
285
+ for await (const chunk of req) {
286
+ body += chunk.toString('utf8')
287
+ if (body.length > 256) { reject(res, 413); return }
288
+ }
289
+ const form = new URLSearchParams(body)
290
+ if (form.size !== 1 || !form.has('ticket')) { reject(res); return }
291
+ const key = form.get('ticket')
292
+ const grant = tickets.get(key)
293
+ if (!grant || grant.expires <= now() || !live(grant.client) || grant.origin !== requestOrigin) { reject(res, 401); return }
294
+ remove(tickets, key)
295
+ if (sessions.size >= MAX_GRANTS) { reject(res, 429); return }
296
+ const id = randomBytes(32).toString('base64url')
297
+ const session = { client: grant.client, sourceTicket: key, origin: requestOrigin, expires: now() + sessionMs, resources: new Set() }
298
+ session.timer = setTimeout(() => remove(sessions, id), sessionMs)
299
+ session.timer.unref?.()
300
+ sessions.set(id, session)
301
+ headers(res)
302
+ res.setHeader('set-cookie', `${UI_COOKIE}=${id}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${Math.floor(sessionMs / 1000)}${requestOrigin.startsWith('https:') ? '; Secure' : ''}`)
303
+ res.writeHead(303, { location: grant.target }).end()
304
+ }
305
+
306
+ async function proxy(req, res, session) {
307
+ const controller = new AbortController()
308
+ const stop = () => { controller.abort(); res.destroy() }
309
+ session.resources.add(stop)
310
+ res.once('close', () => { controller.abort(); session.resources.delete(stop) })
311
+ const hostCookie = await authenticate()
312
+ if (controller.signal.aborted || !live(session.client)) return
313
+ const outgoing = { cookie: hostCookie, 'accept-encoding': 'identity' }
314
+ for (const name of SAFE_REQUEST_HEADERS) if (req.headers[name] !== undefined) outgoing[name] = req.headers[name]
315
+ // A browser Origin is only translated after the exact external-origin check.
316
+ if (req.headers.origin !== undefined) outgoing.origin = origin
317
+ const target = new URL(req.url, origin)
318
+ const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)(target, {
319
+ method: req.method, headers: outgoing, signal: controller.signal,
320
+ }, (response) => {
321
+ void (async () => {
322
+ if (response.statusCode >= 300 && response.statusCode < 400) { response.destroy(); reject(res, 502); return }
323
+ headers(res)
324
+ for (const name of SAFE_RESPONSE_HEADERS) {
325
+ const value = response.headers[name]
326
+ if (value === undefined) continue
327
+ if (secrets.some((secret) => secret && String(value).includes(secret))) throw unavailable()
328
+ res.setHeader(name, value)
329
+ }
330
+ const html = /(?:text\/html|application\/xhtml\+xml)/iu.test(response.headers['content-type'] ?? '')
331
+ if (html) {
332
+ const websocketOrigin = session.origin.replace(/^http/u, 'ws')
333
+ res.setHeader('content-security-policy', `connect-src 'self' ${websocketOrigin}; form-action 'self'; base-uri 'self'; object-src 'none'; frame-src 'self'; frame-ancestors 'none'`)
334
+ }
335
+ if (html && req.method !== 'HEAD') {
336
+ if (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity') throw unavailable()
337
+ const chunks = []
338
+ let size = 0
339
+ for await (const chunk of response) {
340
+ size += chunk.length
341
+ if (size > MAX_HTML_BYTES) throw unavailable()
342
+ chunks.push(chunk)
343
+ }
344
+ const body = browserDocument(Buffer.concat(chunks).toString('utf8'), secrets)
345
+ const output = responseStream(req, res, response)
346
+ res.writeHead(response.statusCode ?? 502)
347
+ output.end(body)
348
+ } else if (req.method !== 'HEAD' && response.statusCode === 200
349
+ && req.url.includes('@deepseek-ai/dsh-client-ui-settings-account/client.js')
350
+ && /(?:java|ecma)script/iu.test(response.headers['content-type'] ?? '')) {
351
+ if (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity') throw unavailable()
352
+ const chunks = []
353
+ let size = 0
354
+ for await (const chunk of response) {
355
+ size += chunk.length
356
+ if (size > MAX_ACCOUNT_SCRIPT_BYTES) throw unavailable()
357
+ chunks.push(chunk)
358
+ }
359
+ const body = accountArtwork(Buffer.concat(chunks).toString('utf8'))
360
+ const output = responseStream(req, res, response)
361
+ res.writeHead(200)
362
+ output.end(body)
363
+ } else {
364
+ if (response.headers['content-encoding']) res.setHeader('content-encoding', response.headers['content-encoding'])
365
+ const output = responseStream(req, res, response)
366
+ res.writeHead(response.statusCode ?? 502)
367
+ response.on('error', () => res.destroy())
368
+ response.pipe(output)
369
+ }
370
+ })().catch(() => { response.destroy(); reject(res, 502) })
371
+ })
372
+ request.on('error', () => reject(res, 502))
373
+ req.on('aborted', () => controller.abort())
374
+ req.pipe(request)
375
+ }
376
+
377
+ return {
378
+ open(args, client) {
379
+ if (!live(client) || !client.meta.uiOrigin) throw unavailable()
380
+ if (args.section !== undefined && args.section !== null && (typeof args.section !== 'string' || args.section.length > 256)) throw Object.assign(new Error('section must be a string'), { code: 'bad-request' })
381
+ reap()
382
+ revoke(client)
383
+ if (tickets.size >= MAX_GRANTS || sessions.size >= MAX_GRANTS) throw unavailable('Too many UI sessions')
384
+ const ticket = randomBytes(32).toString('base64url')
385
+ const params = new URLSearchParams({ 'pocket-ui': '1' })
386
+ if (args.section) params.set('section', args.section)
387
+ const grant = { client, origin: client.meta.uiOrigin, target: `/?${params}`, expires: now() + ticketMs }
388
+ grant.timer = setTimeout(() => remove(tickets, ticket), ticketMs)
389
+ grant.timer.unref?.()
390
+ tickets.set(ticket, grant)
391
+ return { ticket, path: '/pocket-ui/open' }
392
+ },
393
+ revoke,
394
+ async http(req, res) {
395
+ try {
396
+ const parsed = requestPath(req.url)
397
+ if (!parsed) { reject(res); return }
398
+ if (parsed.path === '/pocket-ui/open' && !parsed.search) { await exchange(req, res); return }
399
+ if (!allowedPath(parsed.path) && !pluginPaths.some((path) => parsed.rawPath === path || parsed.rawPath.startsWith(`${path}/`))) { reject(res); return }
400
+ const session = authorized(req)
401
+ if (!session) { reject(res, 401); return }
402
+ if (!['GET', 'HEAD'].includes(req.method) && !sameSite(req, session.origin, true)) { reject(res); return }
403
+ if (parsed.path.startsWith('/assets/pocket-artwork/')) {
404
+ const bytes = artwork.get(parsed.path)
405
+ if (!bytes || !['GET', 'HEAD'].includes(req.method)) { reject(res, 404); return }
406
+ const stop = () => res.destroy()
407
+ session.resources.add(stop)
408
+ res.once('close', () => session.resources.delete(stop))
409
+ headers(res)
410
+ res.writeHead(200, { 'content-type': parsed.path.endsWith('.webp') ? 'image/webp' : 'image/png', 'content-length': bytes.length })
411
+ res.end(req.method === 'HEAD' ? undefined : bytes)
412
+ return
413
+ }
414
+ await proxy(req, res, session)
415
+ } catch { reject(res, 502) }
416
+ },
417
+ async upgrade(req, socket, head) {
418
+ const parsed = requestPath(req.url)
419
+ const session = parsed?.path === '/api/remote.mux' ? authorized(req) : null
420
+ if (!session || !sameSite(req, session.origin, true)) { rejectUpgrade(socket, 401); return }
421
+ let upstream
422
+ let downstream
423
+ const stop = () => { upstream?.terminate(); downstream?.terminate(); socket.destroy() }
424
+ session.resources.add(stop)
425
+ socket.once('close', () => { upstream?.terminate(); session.resources.delete(stop) })
426
+ socket.on('error', stop)
427
+ try {
428
+ const hostCookie = await authenticate()
429
+ if (socket.destroyed || !live(session.client)) { stop(); return }
430
+ const url = new URL(req.url, origin)
431
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
432
+ const protocols = req.headers['sec-websocket-protocol']?.split(',').map((value) => value.trim()) ?? []
433
+ upstream = new WebSocket(url, protocols, { headers: { cookie: hostCookie, origin }, followRedirects: false, handshakeTimeout: 10_000, maxPayload: 32 * 1024 * 1024, perMessageDeflate: false })
434
+ upstream.on('error', stop)
435
+ upstream.on('close', stop)
436
+ upstream.once('open', () => {
437
+ if (socket.destroyed || !live(session.client)) { stop(); return }
438
+ // Only return a protocol actually selected by the Host.
439
+ if (upstream.protocol) req.headers['sec-websocket-protocol'] = upstream.protocol
440
+ else delete req.headers['sec-websocket-protocol']
441
+ try {
442
+ mux.handleUpgrade(req, socket, head, (ws) => {
443
+ downstream = ws
444
+ ws.on('error', stop)
445
+ ws.on('close', stop)
446
+ ws.on('message', (data, binary) => { if (upstream.readyState === WebSocket.OPEN) upstream.send(data, { binary }) })
447
+ upstream.on('message', (data, binary) => { if (ws.readyState === WebSocket.OPEN) ws.send(data, { binary }) })
448
+ })
449
+ } catch { stop() }
450
+ })
451
+ } catch { stop() }
452
+ },
453
+ dispose() {
454
+ if (disposed) return
455
+ disposed = true
456
+ backendAbort.abort()
457
+ clearInterval(sweep)
458
+ for (const map of [tickets, sessions]) for (const key of map.keys()) remove(map, key)
459
+ cookie = ''
460
+ secrets = []
461
+ artwork.clear()
462
+ artworkBytes = 0
463
+ mux.close()
464
+ },
465
+ }
466
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Stable host instance id for LAN mDNS advertisements and phone host:port updates.
3
+ *
4
+ * Adapted from dsh-mobile instance identity patterns
5
+ * (https://github.com/saya-ch/dsh-mobile), Apache-2.0.
6
+ */
7
+ import { createHash, randomBytes } from 'node:crypto'
8
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ const INSTANCE_ID = /^[a-f0-9]{64}$/
12
+
13
+ /**
14
+ * Load or create a 64-hex instance id under dataDir.
15
+ * @param {string} dataDir
16
+ * @returns {string}
17
+ */
18
+ export function loadOrCreateInstanceId(dataDir) {
19
+ const path = join(dataDir, 'instance-id')
20
+ if (existsSync(path)) {
21
+ const raw = readFileSync(path, 'utf8').trim().toLowerCase()
22
+ if (INSTANCE_ID.test(raw)) return raw
23
+ }
24
+ const id = createHash('sha256').update(randomBytes(32)).digest('hex')
25
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 })
26
+ const tmp = `${path}.tmp`
27
+ writeFileSync(tmp, `${id}\n`, { mode: 0o600 })
28
+ chmodSync(tmp, 0o600)
29
+ renameSync(tmp, path)
30
+ return id
31
+ }
32
+
33
+ export { INSTANCE_ID as INSTANCE_ID_RE }