dsh-plugin-mobile-gateway 0.1.17 → 0.3.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/lib/client.js ADDED
@@ -0,0 +1,423 @@
1
+ // Browser half of dsh-plugin-mobile-gateway. The package manifest makes this
2
+ // bundle part of DSH's client-module graph; it contributes one sidebar footer
3
+ // action and one shell overlay without replacing shipped UI seats.
4
+ window.__ModuleLoader__.load({
5
+ id: 'dsh-plugin-mobile-gateway',
6
+ factory: (require) => {
7
+ const React = require('react')
8
+ const module = { exports: {} }
9
+ const exports = module.exports
10
+
11
+ let open = false
12
+ const listeners = new Set()
13
+ const setOpen = (value) => {
14
+ open = value
15
+ for (const listener of listeners) listener()
16
+ }
17
+ const subscribe = (listener) => {
18
+ listeners.add(listener)
19
+ return () => listeners.delete(listener)
20
+ }
21
+ const getOpen = () => open
22
+
23
+ const colors = {
24
+ panel: 'var(--color-bg, #111318)',
25
+ card: 'var(--color-bg-elevated, rgba(127,127,127,.08))',
26
+ text: 'var(--color-text, #f4f4f5)',
27
+ muted: 'var(--color-text-muted, #9297a2)',
28
+ border: 'var(--color-border, rgba(127,127,127,.24))',
29
+ accent: 'var(--color-primary, #4f7cff)',
30
+ danger: '#dc4c64',
31
+ success: '#2fb171',
32
+ }
33
+ const styles = {
34
+ backdrop: {
35
+ position: 'fixed', inset: 0, zIndex: 1200, pointerEvents: 'auto',
36
+ background: 'rgba(0,0,0,.32)', display: 'flex', justifyContent: 'flex-end',
37
+ },
38
+ panel: {
39
+ width: 380, maxWidth: 'calc(100vw - 24px)', height: '100%', overflowY: 'auto',
40
+ background: colors.panel, color: colors.text, borderLeft: `1px solid ${colors.border}`,
41
+ boxShadow: '-12px 0 40px rgba(0,0,0,.22)', padding: 20,
42
+ fontFamily: 'system-ui, sans-serif', fontSize: 13, boxSizing: 'border-box',
43
+ },
44
+ header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 },
45
+ title: { margin: 0, fontSize: 18, fontWeight: 650 },
46
+ card: { background: colors.card, border: `1px solid ${colors.border}`, borderRadius: 12, padding: 14, marginBottom: 12 },
47
+ label: { display: 'block', color: colors.muted, fontSize: 12, marginBottom: 6 },
48
+ input: { width: '100%', boxSizing: 'border-box', border: `1px solid ${colors.border}`, borderRadius: 8, padding: '8px 10px', marginBottom: 10, background: 'transparent', color: 'inherit', outline: 'none' },
49
+ pairingText: { width: '100%', minHeight: 86, boxSizing: 'border-box', resize: 'vertical', border: `1px solid ${colors.border}`, borderRadius: 8, padding: '9px 10px', margin: '10px 0', background: 'transparent', color: 'inherit', outline: 'none', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11, lineHeight: 1.45, overflowWrap: 'anywhere' },
50
+ button: { cursor: 'pointer', border: `1px solid ${colors.border}`, background: 'transparent', borderRadius: 8, padding: '7px 11px', color: 'inherit', font: 'inherit' },
51
+ primary: { background: colors.accent, color: '#fff', borderColor: colors.accent, width: '100%' },
52
+ muted: { color: colors.muted, fontSize: 12, lineHeight: 1.55 },
53
+ row: { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', columnGap: 12, alignItems: 'center', padding: '12px 0', borderBottom: `1px solid ${colors.border}` },
54
+ badge: { display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11, marginLeft: 8, color: colors.muted },
55
+ dot: { width: 7, height: 7, borderRadius: 99, display: 'inline-block' },
56
+ error: { color: '#ff8092', background: 'rgba(220,76,100,.12)', borderRadius: 8, padding: 10, marginBottom: 12 },
57
+ qr: { display: 'block', width: 220, height: 220, margin: '12px auto', background: '#fff', borderRadius: 10, padding: 8 },
58
+ switch: { position: 'relative', display: 'inline-flex', width: 42, height: 24, flexShrink: 0 },
59
+ switchInput: { position: 'absolute', opacity: 0, width: 1, height: 1 },
60
+ switchTrack: { position: 'absolute', inset: 0, borderRadius: 99, transition: 'background .18s ease', cursor: 'pointer' },
61
+ switchKnob: { position: 'absolute', top: 3, width: 18, height: 18, borderRadius: 99, background: '#fff', boxShadow: '0 1px 3px rgba(0,0,0,.35)', transition: 'left .18s ease', pointerEvents: 'none' },
62
+ }
63
+
64
+ async function request(path, options) {
65
+ const response = await fetch(path, { credentials: 'same-origin', ...options })
66
+ const body = await response.json().catch(() => ({}))
67
+ if (!response.ok) throw new Error(body.message || body.error || `HTTP ${response.status}`)
68
+ return body
69
+ }
70
+
71
+ function inferredUrl(wsPath) {
72
+ const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
73
+ return `${scheme}//${window.location.host}${wsPath || '/ws/mobile'}`
74
+ }
75
+
76
+ function DeviceRow({ device, onRevoke, revoking }) {
77
+ const triggerRevoke = (event) => {
78
+ event.preventDefault()
79
+ event.stopPropagation()
80
+ if (!revoking) onRevoke(device)
81
+ }
82
+ return React.createElement('div', { style: styles.row },
83
+ React.createElement('div', { style: { minWidth: 0, overflow: 'hidden' } },
84
+ React.createElement('div', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
85
+ device.name,
86
+ React.createElement('span', { style: styles.badge },
87
+ React.createElement('span', { style: { ...styles.dot, background: device.online ? colors.success : colors.muted } }),
88
+ device.online ? `在线${device.connections > 1 ? ` · ${device.connections} 个连接` : ''}` : '离线')),
89
+ React.createElement('div', { style: { ...styles.muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginTop: 3 } },
90
+ device.lastSeenAt ? `最近连接:${new Date(device.lastSeenAt).toLocaleString()}` : '尚未连接')),
91
+ React.createElement('button', {
92
+ type: 'button',
93
+ disabled: revoking,
94
+ style: {
95
+ ...styles.button,
96
+ color: colors.danger,
97
+ background: 'transparent',
98
+ borderColor: 'rgba(220,76,100,.55)',
99
+ flexShrink: 0,
100
+ minWidth: 54,
101
+ opacity: revoking ? .6 : 1,
102
+ },
103
+ title: `吊销 ${device.name}`,
104
+ // Pointer-up is more reliable than click inside the shell overlay on
105
+ // touch devices. The click handler remains for keyboard activation.
106
+ onPointerUp: triggerRevoke,
107
+ onClick: (event) => {
108
+ if (event.detail === 0) triggerRevoke(event)
109
+ },
110
+ }, revoking ? '吊销中…' : '吊销'))
111
+ }
112
+
113
+ function DevicePanel() {
114
+ const [devices, setDevices] = React.useState(null)
115
+ const [status, setStatus] = React.useState(null)
116
+ const [publicUrl, setPublicUrl] = React.useState('')
117
+ const [deviceName, setDeviceName] = React.useState('iPhone')
118
+ const [qr, setQr] = React.useState(null)
119
+ const [error, setError] = React.useState(null)
120
+ const [busy, setBusy] = React.useState(false)
121
+ const [refreshing, setRefreshing] = React.useState(false)
122
+ const [refreshNotice, setRefreshNotice] = React.useState(null)
123
+ const [pairingTokenCopied, setPairingTokenCopied] = React.useState(false)
124
+ const [deviceNotice, setDeviceNotice] = React.useState(null)
125
+ const [deviceNoticeError, setDeviceNoticeError] = React.useState(false)
126
+ const [revokingIds, setRevokingIds] = React.useState(() => new Set())
127
+ const revokingIdsRef = React.useRef(new Set())
128
+
129
+ const refresh = React.useCallback(async () => {
130
+ try {
131
+ const [deviceData, statusData] = await Promise.all([
132
+ request('/mgw/devices'),
133
+ request('/mgw/status'),
134
+ ])
135
+ const nextDevices = deviceData.devices || []
136
+ // Keep existing DOM rows alive when polling returns identical data.
137
+ // Replacing them every three seconds can swallow a pointer/click that
138
+ // started on the old button and ended after React replaced the row.
139
+ if (revokingIdsRef.current.size === 0) {
140
+ setDevices((current) => JSON.stringify(current) === JSON.stringify(nextDevices) ? current : nextDevices)
141
+ }
142
+ setStatus((current) => JSON.stringify(current) === JSON.stringify(statusData) ? current : statusData)
143
+ setPublicUrl((current) => current || statusData.publicUrl || inferredUrl(statusData.wsPath))
144
+ return true
145
+ } catch (cause) {
146
+ setError(cause.message)
147
+ return false
148
+ }
149
+ }, [])
150
+
151
+ const manualRefresh = async () => {
152
+ if (refreshing) return
153
+ setRefreshing(true)
154
+ setRefreshNotice(null)
155
+ const succeeded = await refresh()
156
+ setRefreshing(false)
157
+ if (succeeded) {
158
+ setError(null)
159
+ setRefreshNotice(`已刷新 · ${new Date().toLocaleTimeString()}`)
160
+ window.setTimeout(() => setRefreshNotice(null), 2200)
161
+ }
162
+ }
163
+
164
+ React.useEffect(() => {
165
+ let active = true
166
+ refresh()
167
+ const timer = window.setInterval(refresh, 3000)
168
+ return () => { active = false; window.clearInterval(timer) }
169
+ }, [refresh])
170
+
171
+ const pair = async () => {
172
+ setBusy(true)
173
+ setError(null)
174
+ setPairingTokenCopied(false)
175
+ try {
176
+ const data = await request('/mgw/pair', {
177
+ method: 'POST',
178
+ headers: { 'Content-Type': 'application/json' },
179
+ body: JSON.stringify({ name: deviceName, publicUrl }),
180
+ })
181
+ setQr(data)
182
+ } catch (cause) {
183
+ setError(cause.message)
184
+ } finally {
185
+ setBusy(false)
186
+ }
187
+ }
188
+
189
+ const copyPairingText = async () => {
190
+ if (!qr || !qr.qrPayload) return
191
+ try {
192
+ if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') {
193
+ throw new Error('当前浏览器不允许直接写入剪贴板,请选中文本后手动复制')
194
+ }
195
+ await navigator.clipboard.writeText(qr.qrPayload)
196
+ setError(null)
197
+ setPairingTokenCopied(true)
198
+ window.setTimeout(() => setPairingTokenCopied(false), 2200)
199
+ } catch (cause) {
200
+ setPairingTokenCopied(false)
201
+ setError(cause.message)
202
+ }
203
+ }
204
+
205
+ const toggleGateway = async () => {
206
+ const enabled = !(status && status.gatewayEnabled)
207
+ setBusy(true)
208
+ setError(null)
209
+ try {
210
+ const data = await request('/mgw/gateway', {
211
+ method: 'POST',
212
+ headers: { 'Content-Type': 'application/json' },
213
+ body: JSON.stringify({ enabled }),
214
+ })
215
+ setStatus((current) => ({ ...(current || {}), ...data }))
216
+ if (!enabled) setQr(null)
217
+ } catch (cause) {
218
+ setError(cause.message)
219
+ } finally {
220
+ setBusy(false)
221
+ }
222
+ }
223
+
224
+ const toggleAuth = async () => {
225
+ const enabled = !(status && status.requireAuth)
226
+ if (!enabled && !window.confirm('关闭设备鉴权后,任何能访问移动网关的人都可以控制 DSH。此选项仅限 Debug 阶段使用,确定继续吗?')) return
227
+ setBusy(true)
228
+ setError(null)
229
+ try {
230
+ const data = await request('/mgw/auth', {
231
+ method: 'POST',
232
+ headers: { 'Content-Type': 'application/json' },
233
+ body: JSON.stringify({ enabled }),
234
+ })
235
+ setStatus((current) => ({ ...(current || {}), requireAuth: data.requireAuth }))
236
+ } catch (cause) {
237
+ setError(cause.message)
238
+ } finally {
239
+ setBusy(false)
240
+ }
241
+ }
242
+
243
+ const revoke = async (device) => {
244
+ if (revokingIdsRef.current.has(device.id)) return
245
+ setDeviceNotice(null)
246
+ setDeviceNoticeError(false)
247
+ setError(null)
248
+ revokingIdsRef.current.add(device.id)
249
+ setRevokingIds((current) => new Set(current).add(device.id))
250
+ try {
251
+ await request(`/mgw/devices/${encodeURIComponent(device.id)}/revoke`, { method: 'POST' })
252
+ setDevices((current) => Array.isArray(current) ? current.filter((item) => item.id !== device.id) : current)
253
+ setDeviceNotice(`已吊销 ${device.name}`)
254
+ window.setTimeout(() => setDeviceNotice(null), 2200)
255
+ } catch (cause) {
256
+ setDeviceNoticeError(true)
257
+ setDeviceNotice(`吊销失败:${cause.message}`)
258
+ setError(cause.message)
259
+ } finally {
260
+ revokingIdsRef.current.delete(device.id)
261
+ setRevokingIds((current) => {
262
+ const next = new Set(current)
263
+ next.delete(device.id)
264
+ return next
265
+ })
266
+ await refresh()
267
+ }
268
+ }
269
+
270
+ return React.createElement('div', { style: styles.backdrop, onMouseDown: () => setOpen(false) },
271
+ React.createElement('aside', { style: styles.panel, onMouseDown: (event) => event.stopPropagation(), role: 'dialog', 'aria-modal': true, 'aria-label': '移动设备管理' },
272
+ React.createElement('div', { style: styles.header },
273
+ React.createElement('div', null,
274
+ React.createElement('h2', { style: styles.title }, '移动设备'),
275
+ React.createElement('div', { style: styles.muted }, status && status.gatewayEnabled ? '移动网关已开启 · 设备鉴权生效中' : '移动网关已关闭 · 普通 WebUI 模式')),
276
+ React.createElement('button', { style: styles.button, onClick: () => setOpen(false), 'aria-label': '关闭' }, '✕')),
277
+ error ? React.createElement('div', { style: styles.error }, error) : null,
278
+ React.createElement('section', { style: styles.card },
279
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
280
+ React.createElement('div', null,
281
+ React.createElement('strong', null, '允许移动设备连接'),
282
+ React.createElement('div', { style: { ...styles.muted, marginTop: 4 } }, status && status.gatewayEnabled
283
+ ? status.connectedClients > 0
284
+ ? `${status.connectedClients} 个连接,网关保持开启`
285
+ : `等待可信设备连接${status.waitExpiresAt ? `,约 ${Math.max(1, Math.ceil((status.waitExpiresAt - Date.now()) / 60000))} 分钟后自动关闭` : ''}`
286
+ : '关闭时不会接受移动设备连接')),
287
+ React.createElement('label', { style: { ...styles.switch, opacity: busy || !status ? .55 : 1 }, title: status && status.gatewayEnabled ? '关闭移动网关' : '开启移动网关' },
288
+ React.createElement('input', {
289
+ type: 'checkbox',
290
+ role: 'switch',
291
+ 'aria-label': '允许移动设备连接',
292
+ 'aria-checked': !!(status && status.gatewayEnabled),
293
+ checked: !!(status && status.gatewayEnabled),
294
+ disabled: busy || !status,
295
+ onChange: toggleGateway,
296
+ style: styles.switchInput,
297
+ }),
298
+ React.createElement('span', { style: { ...styles.switchTrack, background: status && status.gatewayEnabled ? colors.success : colors.muted } }),
299
+ React.createElement('span', { style: { ...styles.switchKnob, left: status && status.gatewayEnabled ? 21 : 3 } })))),
300
+ React.createElement('section', { style: styles.card },
301
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
302
+ React.createElement('div', null,
303
+ React.createElement('strong', null, '设备鉴权'),
304
+ React.createElement('div', { style: { ...styles.muted, marginTop: 4 } }, status && status.requireAuth ? '已开启,仅允许可信设备连接' : '已关闭,仅限 Debug 阶段使用')),
305
+ React.createElement('label', { style: { ...styles.switch, opacity: busy || !status ? .55 : 1 }, title: status && status.requireAuth ? '关闭设备鉴权' : '开启设备鉴权' },
306
+ React.createElement('input', {
307
+ type: 'checkbox',
308
+ role: 'switch',
309
+ 'aria-label': '设备鉴权',
310
+ 'aria-checked': !!(status && status.requireAuth),
311
+ checked: !!(status && status.requireAuth),
312
+ disabled: busy || !status,
313
+ onChange: toggleAuth,
314
+ style: styles.switchInput,
315
+ }),
316
+ React.createElement('span', { style: { ...styles.switchTrack, background: status && status.requireAuth ? colors.success : colors.danger } }),
317
+ React.createElement('span', { style: { ...styles.switchKnob, left: status && status.requireAuth ? 21 : 3 } }))),
318
+ status && !status.requireAuth
319
+ ? React.createElement('p', { style: { ...styles.error, margin: '12px 0 0' } }, 'Debug 模式:移动网关连接将跳过设备凭证校验,请勿暴露到公网。')
320
+ : null),
321
+ React.createElement('section', { style: styles.card },
322
+ React.createElement('label', { style: styles.label }, '公网 WebSocket 地址'),
323
+ React.createElement('input', { style: styles.input, value: publicUrl, onChange: (event) => setPublicUrl(event.target.value), placeholder: 'wss://gateway.example.com/ws/mobile', spellCheck: false }),
324
+ React.createElement('label', { style: styles.label }, '设备名称'),
325
+ React.createElement('input', { style: styles.input, value: deviceName, onChange: (event) => setDeviceName(event.target.value), maxLength: 80, placeholder: 'iPhone' }),
326
+ React.createElement('button', { style: { ...styles.button, ...styles.primary, opacity: busy || !(status && status.gatewayEnabled) ? .65 : 1 }, disabled: busy || !(status && status.gatewayEnabled), onClick: pair }, busy ? '正在生成…' : status && status.gatewayEnabled ? '生成配对二维码' : '请先开启移动网关'),
327
+ React.createElement('p', { style: { ...styles.muted, margin: '10px 0 0' } }, '公网地址必须使用 wss://。二维码中的配对码只能使用一次,并在 5 分钟内过期。')),
328
+ qr ? React.createElement('section', { style: { ...styles.card, textAlign: 'center' } },
329
+ React.createElement('strong', null, '使用 iOS 客户端扫码'),
330
+ React.createElement('img', { style: styles.qr, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(qr.svg)}`, alt: '一次性设备配对二维码' }),
331
+ React.createElement('label', { style: { ...styles.label, textAlign: 'left' } }, 'Base64URL 配对字符串'),
332
+ React.createElement('textarea', { style: styles.pairingText, value: qr.qrPayload, readOnly: true, spellCheck: false, onFocus: (event) => event.target.select() }),
333
+ React.createElement('button', { style: { ...styles.button, width: '100%', marginBottom: 8, borderColor: pairingTokenCopied ? colors.success : colors.border, color: pairingTokenCopied ? colors.success : colors.text }, onClick: copyPairingText }, pairingTokenCopied ? '✓ 已复制' : '复制配对 Token'),
334
+ pairingTokenCopied ? React.createElement('div', { style: { ...styles.muted, color: colors.success, marginBottom: 8 } }, '已复制到剪贴板') : null,
335
+ React.createElement('div', { style: styles.muted }, `有效期至 ${new Date(qr.pairing.expiresAt).toLocaleTimeString()};扫码成功后此二维码立即失效。`)) : null,
336
+ React.createElement('section', { style: styles.card },
337
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
338
+ React.createElement('strong', null, '可信设备'),
339
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 10 } },
340
+ deviceNotice || refreshNotice ? React.createElement('span', {
341
+ style: { ...styles.muted, color: deviceNoticeError ? colors.danger : colors.success },
342
+ }, deviceNotice || refreshNotice) : null,
343
+ React.createElement('button', {
344
+ style: { ...styles.button, opacity: refreshing ? .6 : 1 },
345
+ disabled: refreshing,
346
+ onClick: manualRefresh,
347
+ }, refreshing ? '刷新中…' : '刷新'))),
348
+ devices === null
349
+ ? React.createElement('p', { style: styles.muted }, '加载中…')
350
+ : devices.length === 0
351
+ ? React.createElement('p', { style: styles.muted }, '暂无已配对设备。')
352
+ : devices.map((device) => React.createElement(DeviceRow, {
353
+ key: device.id,
354
+ device,
355
+ onRevoke: revoke,
356
+ revoking: revokingIds.has(device.id),
357
+ })))))
358
+ }
359
+
360
+ function FooterButton(props) {
361
+ const isOpen = React.useSyncExternalStore(subscribe, getOpen)
362
+ const wide = !!props.wide
363
+ const iconSize = wide ? 16 : 18
364
+ const icon = React.createElement('svg', {
365
+ width: iconSize,
366
+ height: iconSize,
367
+ viewBox: '0 0 24 24',
368
+ fill: 'none',
369
+ stroke: 'currentColor',
370
+ strokeWidth: 1.8,
371
+ strokeLinecap: 'round',
372
+ strokeLinejoin: 'round',
373
+ 'aria-hidden': true,
374
+ },
375
+ React.createElement('rect', { x: 6, y: 2, width: 12, height: 20, rx: 2.5 }),
376
+ React.createElement('path', { d: 'M10 18h4' }))
377
+ return React.createElement('button', {
378
+ title: '移动设备管理',
379
+ 'aria-label': '移动设备管理',
380
+ 'aria-pressed': isOpen,
381
+ onClick: () => setOpen(!isOpen),
382
+ style: {
383
+ cursor: 'pointer',
384
+ background: 'transparent',
385
+ border: 'none',
386
+ color: 'inherit',
387
+ font: 'inherit',
388
+ display: 'flex',
389
+ alignItems: 'center',
390
+ justifyContent: wide ? 'flex-start' : 'center',
391
+ gap: wide ? 8 : 0,
392
+ boxSizing: 'border-box',
393
+ width: wide ? 'calc(100% + 4px)' : 36,
394
+ height: wide ? 42 : 36,
395
+ margin: wide ? '4px -2px' : '3px 0',
396
+ padding: wide ? '0 10px 0 8px' : 0,
397
+ lineHeight: wide ? '22px' : 'normal',
398
+ },
399
+ }, icon, wide ? React.createElement('span', null, '移动设备') : null)
400
+ }
401
+
402
+ function OverlayEntry() {
403
+ const isOpen = React.useSyncExternalStore(subscribe, getOpen)
404
+ return isOpen ? React.createElement(DevicePanel) : null
405
+ }
406
+
407
+ const inject = ['slots']
408
+ function apply(ctx) {
409
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register(
410
+ { name: 'sidebar.footer.action', id: 'mobile-gateway-devices', order: 80, label: '移动设备' },
411
+ (props) => React.createElement(FooterButton, props),
412
+ ))
413
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register(
414
+ { name: 'shell.overlay', id: 'mobile-gateway-devices-panel', order: 100, label: '移动设备' },
415
+ () => React.createElement(OverlayEntry),
416
+ ))
417
+ }
418
+
419
+ exports.apply = apply
420
+ exports.inject = inject
421
+ return module.exports
422
+ },
423
+ })
package/lib/devices.js ADDED
@@ -0,0 +1,209 @@
1
+ // Persistent paired-device registry for mobile-gateway authentication.
2
+ //
3
+ // Long-lived device tokens are generated with 256 bits of entropy and are
4
+ // never written to disk. Only their SHA-256 digests are persisted. Pairing
5
+ // codes are one-time, short-lived, and memory-only, so a restart invalidates
6
+ // every outstanding QR code without affecting already paired devices.
7
+ 'use strict'
8
+
9
+ const fs = require('node:fs')
10
+ const crypto = require('node:crypto')
11
+ const path = require('node:path')
12
+
13
+ const STORE_VERSION = 3
14
+ const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
15
+
16
+ function digest(secret) {
17
+ return crypto.createHash('sha256').update(secret, 'utf8').digest('hex')
18
+ }
19
+
20
+ function safeEqualHex(left, right) {
21
+ if (typeof left !== 'string' || typeof right !== 'string') return false
22
+ const a = Buffer.from(left, 'hex')
23
+ const b = Buffer.from(right, 'hex')
24
+ return a.length === 32 && b.length === 32 && crypto.timingSafeEqual(a, b)
25
+ }
26
+
27
+ function publicDevice(device, connections) {
28
+ return {
29
+ id: device.id,
30
+ name: device.name,
31
+ createdAt: device.createdAt,
32
+ lastSeenAt: device.lastSeenAt || null,
33
+ online: connections > 0,
34
+ connections,
35
+ }
36
+ }
37
+
38
+ function normalizeClientDeviceId(value) {
39
+ if (typeof value !== 'string') return undefined
40
+ const normalized = value.trim()
41
+ return /^[A-Za-z0-9._:-]{8,128}$/.test(normalized) ? normalized : undefined
42
+ }
43
+
44
+ function createRegistry(file, options = {}) {
45
+ const pairingTtlMs = Number.isSafeInteger(options.pairingTtlMs) && options.pairingTtlMs > 0
46
+ ? options.pairingTtlMs
47
+ : DEFAULT_PAIRING_TTL_MS
48
+ let devices = []
49
+ const pairings = new Map()
50
+ const online = new Map()
51
+
52
+ const save = () => {
53
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
54
+ const tmp = `${file}.${process.pid}.tmp`
55
+ fs.writeFileSync(tmp, JSON.stringify({ version: STORE_VERSION, devices }, null, 2), { mode: 0o600 })
56
+ fs.chmodSync(tmp, 0o600)
57
+ fs.renameSync(tmp, file)
58
+ }
59
+
60
+ const load = () => {
61
+ try {
62
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
63
+ if (!Array.isArray(parsed.devices)) return
64
+ let migrated = false
65
+ devices = parsed.devices.flatMap((row) => {
66
+ if (!row || typeof row.id !== 'string' || typeof row.name !== 'string') return []
67
+ let tokenHash = typeof row.tokenHash === 'string' ? row.tokenHash : undefined
68
+ // Migrate the v1 development format without invalidating an existing
69
+ // device. The plaintext is removed on the next atomic save.
70
+ if (!tokenHash && typeof row.token === 'string' && row.token !== '') {
71
+ tokenHash = digest(row.token)
72
+ migrated = true
73
+ }
74
+ if (!tokenHash || !/^[a-f0-9]{64}$/.test(tokenHash)) return []
75
+ return [{
76
+ id: row.id,
77
+ name: row.name.slice(0, 80),
78
+ clientDeviceId: normalizeClientDeviceId(row.clientDeviceId),
79
+ tokenHash,
80
+ createdAt: Number.isFinite(row.createdAt) ? row.createdAt : Date.now(),
81
+ lastSeenAt: Number.isFinite(row.lastSeenAt) ? row.lastSeenAt : null,
82
+ revokedAt: Number.isFinite(row.revokedAt) ? row.revokedAt : (row.revoked ? Date.now() : null),
83
+ }]
84
+ })
85
+ if (migrated || parsed.version !== STORE_VERSION) save()
86
+ else fs.chmodSync(file, 0o600)
87
+ } catch (error) {
88
+ if (error && error.code !== 'ENOENT') throw new Error(`failed to load device registry: ${error.message}`, { cause: error })
89
+ }
90
+ }
91
+
92
+ const prunePairings = (now = Date.now()) => {
93
+ for (const [codeHash, pairing] of pairings) {
94
+ if (pairing.expiresAt <= now) pairings.delete(codeHash)
95
+ }
96
+ }
97
+
98
+ load()
99
+
100
+ return {
101
+ list() {
102
+ return devices
103
+ .filter((device) => !device.revokedAt)
104
+ .map((device) => publicDevice(device, online.get(device.id) || 0))
105
+ },
106
+
107
+ count() {
108
+ return devices.filter((device) => !device.revokedAt).length
109
+ },
110
+
111
+ createPairing(name) {
112
+ prunePairings()
113
+ const code = crypto.randomBytes(32).toString('base64url')
114
+ const pairing = {
115
+ id: crypto.randomUUID(),
116
+ name: typeof name === 'string' && name.trim() !== '' ? name.trim().slice(0, 80) : '未命名设备',
117
+ expiresAt: Date.now() + pairingTtlMs,
118
+ }
119
+ pairings.set(digest(code), pairing)
120
+ return { ...pairing, code }
121
+ },
122
+
123
+ hasPairing(code) {
124
+ prunePairings()
125
+ return typeof code === 'string' && pairings.has(digest(code))
126
+ },
127
+
128
+ claimPairing(code, clientDeviceId) {
129
+ prunePairings()
130
+ if (typeof code !== 'string' || code === '') return undefined
131
+ const codeHash = digest(code)
132
+ const pairing = pairings.get(codeHash)
133
+ if (!pairing) return undefined
134
+ // Delete before doing any further work: claiming is single-use even if a
135
+ // later socket upgrade fails.
136
+ pairings.delete(codeHash)
137
+ const token = crypto.randomBytes(32).toString('base64url')
138
+ const normalizedClientDeviceId = normalizeClientDeviceId(clientDeviceId)
139
+ let device = normalizedClientDeviceId
140
+ ? devices.find((candidate) => !candidate.revokedAt && candidate.clientDeviceId === normalizedClientDeviceId)
141
+ : undefined
142
+ if (device) {
143
+ // A valid one-time pairing code authorizes credential rotation. Keep
144
+ // the stable server-side device record while replacing its token, so
145
+ // re-pairing one iOS installation never creates duplicate rows.
146
+ device.name = pairing.name
147
+ device.tokenHash = digest(token)
148
+ device.clientDeviceId = normalizedClientDeviceId
149
+ } else {
150
+ device = {
151
+ id: pairing.id,
152
+ name: pairing.name,
153
+ clientDeviceId: normalizedClientDeviceId,
154
+ tokenHash: digest(token),
155
+ createdAt: Date.now(),
156
+ lastSeenAt: null,
157
+ revokedAt: null,
158
+ }
159
+ devices.push(device)
160
+ }
161
+ save()
162
+ return { device: publicDevice(device, 0), token }
163
+ },
164
+
165
+ authenticate(token, clientDeviceId) {
166
+ if (typeof token !== 'string' || token === '') return undefined
167
+ const tokenHash = digest(token)
168
+ const device = devices.find((candidate) => !candidate.revokedAt && safeEqualHex(candidate.tokenHash, tokenHash))
169
+ const normalizedClientDeviceId = normalizeClientDeviceId(clientDeviceId)
170
+ if (device && normalizedClientDeviceId && !device.clientDeviceId) {
171
+ // Migration path for devices paired before installation IDs existed:
172
+ // a valid long-lived token proves ownership of this record, so it is
173
+ // safe to bind the client's stable ID without creating a new device.
174
+ const conflict = devices.some((candidate) => candidate !== device && !candidate.revokedAt && candidate.clientDeviceId === normalizedClientDeviceId)
175
+ if (!conflict) {
176
+ device.clientDeviceId = normalizedClientDeviceId
177
+ save()
178
+ }
179
+ }
180
+ return device ? publicDevice(device, online.get(device.id) || 0) : undefined
181
+ },
182
+
183
+ connected(deviceId) {
184
+ const device = devices.find((candidate) => candidate.id === deviceId && !candidate.revokedAt)
185
+ if (!device) return false
186
+ online.set(deviceId, (online.get(deviceId) || 0) + 1)
187
+ device.lastSeenAt = Date.now()
188
+ save()
189
+ return true
190
+ },
191
+
192
+ disconnected(deviceId) {
193
+ const count = online.get(deviceId) || 0
194
+ if (count <= 1) online.delete(deviceId)
195
+ else online.set(deviceId, count - 1)
196
+ },
197
+
198
+ revoke(deviceId) {
199
+ const index = devices.findIndex((candidate) => candidate.id === deviceId && !candidate.revokedAt)
200
+ if (index < 0) return false
201
+ devices.splice(index, 1)
202
+ online.delete(deviceId)
203
+ save()
204
+ return true
205
+ },
206
+ }
207
+ }
208
+
209
+ module.exports = { createRegistry, digest }