@huakirax/dsh-plugin-pocket 0.0.0-stage → 0.1.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/LICENSE +9 -0
- package/README.md +237 -2
- package/bin/pocket.js +31 -0
- package/cordis.patch.yml +6 -0
- package/index.js +360 -0
- package/lib/client.js +2453 -0
- package/package.json +91 -4
- package/src/advertise-host.js +69 -0
- package/src/approval-hook.js +50 -0
- package/src/client/ConfirmDialog.tsx +80 -0
- package/src/client/HostUiLauncher.tsx +101 -0
- package/src/client/ManagePanel.tsx +218 -0
- package/src/client/PhoneIcon.tsx +48 -0
- package/src/client/PocketSettings.tsx +13 -0
- package/src/client/api.ts +97 -0
- package/src/client/completion-sync.js +289 -0
- package/src/client/host-ui-styles.ts +62 -0
- package/src/client/index.ts +47 -0
- package/src/client/locales.ts +75 -0
- package/src/client/nav-icon.ts +31 -0
- package/src/client/qr.ts +15 -0
- package/src/client/styles.ts +76 -0
- package/src/completion-route.js +87 -0
- package/src/completion-tracker.js +95 -0
- package/src/devices.js +60 -0
- package/src/extra-ops.js +604 -0
- package/src/host-ui.js +466 -0
- package/src/instance-id.js +33 -0
- package/src/iroh.js +260 -0
- package/src/manage.js +122 -0
- package/src/mdns-advertise.js +75 -0
- package/src/office-preview.js +129 -0
- package/src/pairing-uri.js +42 -0
- package/src/pairing.js +34 -0
- package/src/parity-ops.js +250 -0
- package/src/server.js +761 -0
- package/src/settings-ops.js +387 -0
- package/src/task-events.js +77 -0
- package/src/task-fallback.js +177 -0
- package/src/token.js +18 -0
- package/src/transport-pref.js +47 -0
- package/src/trust.js +25 -0
- package/src/user-question-hook.js +93 -0
- 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('&', '&').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 }
|