@linxin666/dsh-remote-web-ui 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 +28 -0
- package/README.md +346 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +2817 -0
- package/lib/index.js +1455 -0
- package/lib/invariant.js +21 -0
- package/lib/types/client/PairFailedNotice.d.ts +12 -0
- package/lib/types/client/PairFailedNotice.d.ts.map +1 -0
- package/lib/types/client/PhoneIcon.d.ts +19 -0
- package/lib/types/client/PhoneIcon.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +78 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/RemoteEntry.d.ts +10 -0
- package/lib/types/client/RemoteEntry.d.ts.map +1 -0
- package/lib/types/client/RemotePanel.d.ts +50 -0
- package/lib/types/client/RemotePanel.d.ts.map +1 -0
- package/lib/types/client/RemoteSettingsCard.d.ts +75 -0
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -0
- package/lib/types/client/deep-link.d.ts +40 -0
- package/lib/types/client/deep-link.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +54 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +144 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/pair-api.d.ts +100 -0
- package/lib/types/client/pair-api.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +119 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/gate.d.ts +58 -0
- package/lib/types/gate.d.ts.map +1 -0
- package/lib/types/index.d.ts +78 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/lan.d.ts +15 -0
- package/lib/types/lan.d.ts.map +1 -0
- package/lib/types/mobile/api.d.ts +64 -0
- package/lib/types/mobile/api.d.ts.map +1 -0
- package/lib/types/mobile/index.d.ts +10 -0
- package/lib/types/mobile/index.d.ts.map +1 -0
- package/lib/types/mobile/messages.d.ts +103 -0
- package/lib/types/mobile/messages.d.ts.map +1 -0
- package/lib/types/mobile/messages.test.d.ts +2 -0
- package/lib/types/mobile/messages.test.d.ts.map +1 -0
- package/lib/types/mobile/mobile-styles.d.ts +6 -0
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -0
- package/lib/types/mobile/mobile-theme.d.ts +22 -0
- package/lib/types/mobile/mobile-theme.d.ts.map +1 -0
- package/lib/types/mobile/mobile-theme.test.d.ts +2 -0
- package/lib/types/mobile/mobile-theme.test.d.ts.map +1 -0
- package/lib/types/mobile/mux.d.ts +52 -0
- package/lib/types/mobile/mux.d.ts.map +1 -0
- package/lib/types/mobile/rpc.d.ts +38 -0
- package/lib/types/mobile/rpc.d.ts.map +1 -0
- package/lib/types/mobile/theme-toggle.d.ts +12 -0
- package/lib/types/mobile/theme-toggle.d.ts.map +1 -0
- package/lib/types/mobile/views/App.d.ts +59 -0
- package/lib/types/mobile/views/App.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.d.ts +39 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.test.d.ts +2 -0
- package/lib/types/mobile/views/ChatView.test.d.ts.map +1 -0
- package/lib/types/mobile/views/SessionListView.d.ts +27 -0
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -0
- package/lib/types/mobile/views/SessionListView.test.d.ts +2 -0
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +1 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts +19 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -0
- package/lib/types/mobile-api.d.ts +41 -0
- package/lib/types/mobile-api.d.ts.map +1 -0
- package/lib/types/mobile-routes.d.ts +16 -0
- package/lib/types/mobile-routes.d.ts.map +1 -0
- package/lib/types/pairing.d.ts +215 -0
- package/lib/types/pairing.d.ts.map +1 -0
- package/lib/types/routes.d.ts +58 -0
- package/lib/types/routes.d.ts.map +1 -0
- package/lib/types/tunnel.d.ts +99 -0
- package/lib/types/tunnel.d.ts.map +1 -0
- package/package.json +104 -0
- package/src/client/PairFailedNotice.tsx +33 -0
- package/src/client/PhoneIcon.tsx +31 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/RemoteEntry.tsx +202 -0
- package/src/client/RemotePanel.tsx +186 -0
- package/src/client/RemoteSettingsCard.tsx +220 -0
- package/src/client/deep-link.ts +137 -0
- package/src/client/index.ts +167 -0
- package/src/client/locales.ts +149 -0
- package/src/client/pair-api.ts +176 -0
- package/src/client/remote.module.css +422 -0
- package/src/client/settings-card.module.css +296 -0
- package/src/client/settings-form.ts +294 -0
- package/src/css-modules.d.ts +6 -0
- package/src/gate.ts +121 -0
- package/src/index.ts +299 -0
- package/src/invariant.ts +32 -0
- package/src/lan.ts +21 -0
- package/src/mobile/api.ts +106 -0
- package/src/mobile/index.tsx +27 -0
- package/src/mobile/messages.test.ts +222 -0
- package/src/mobile/messages.ts +555 -0
- package/src/mobile/mobile-styles.ts +853 -0
- package/src/mobile/mobile-theme.test.ts +84 -0
- package/src/mobile/mobile-theme.ts +76 -0
- package/src/mobile/mux.ts +126 -0
- package/src/mobile/rpc.ts +90 -0
- package/src/mobile/theme-toggle.tsx +47 -0
- package/src/mobile/views/App.tsx +152 -0
- package/src/mobile/views/ChatView.test.tsx +243 -0
- package/src/mobile/views/ChatView.tsx +650 -0
- package/src/mobile/views/SessionListView.test.tsx +139 -0
- package/src/mobile/views/SessionListView.tsx +186 -0
- package/src/mobile/views/WorkspaceView.tsx +108 -0
- package/src/mobile-api.ts +271 -0
- package/src/mobile-routes.ts +77 -0
- package/src/pairing.ts +390 -0
- package/src/routes.ts +346 -0
- package/src/tunnel.ts +264 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staged form model behind the plugin settings card. A card stages what the
|
|
3
|
+
* user types and writes it only when they save — the settings write is a
|
|
4
|
+
* durable, revision-fenced document mutation, so staging keeps what is on
|
|
5
|
+
* screen exactly what a save would store. Mirrors the official
|
|
6
|
+
* ui-plugin-config card-store pattern in a self-contained slice: this
|
|
7
|
+
* package must not depend on a sibling UI package.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
11
|
+
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
12
|
+
|
|
13
|
+
/** The write one field's staged text performs when the card is saved. */
|
|
14
|
+
export type FieldWrite =
|
|
15
|
+
| { kind: 'set'; value: unknown }
|
|
16
|
+
| { kind: 'clear' }
|
|
17
|
+
|
|
18
|
+
/** How one field converts between its stored value and its draft text. */
|
|
19
|
+
export interface FieldSpec {
|
|
20
|
+
/** Field name inside the namespace section. */
|
|
21
|
+
field: string
|
|
22
|
+
/** Render a stored value as draft text; the empty string when the section carries none. */
|
|
23
|
+
format: (value: unknown) => string
|
|
24
|
+
/**
|
|
25
|
+
* The write this draft text stages, or undefined when the text is not a
|
|
26
|
+
* value this field accepts — which blocks the save rather than discarding it.
|
|
27
|
+
*/
|
|
28
|
+
parse: (text: string) => FieldWrite | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One field as the card renders it. */
|
|
32
|
+
export interface FieldState {
|
|
33
|
+
/** Draft text the control renders. */
|
|
34
|
+
text: string
|
|
35
|
+
/** Whether saving would leave a user-layer entry for this field. */
|
|
36
|
+
overridden: boolean
|
|
37
|
+
/** Whether the draft is not a value this field accepts, which blocks saving. */
|
|
38
|
+
invalid: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Form state every plugin settings card shares. */
|
|
42
|
+
export interface CardShell {
|
|
43
|
+
/** False while the namespace is not served to this client; the card renders nothing. */
|
|
44
|
+
available: boolean
|
|
45
|
+
/** Whether the Host document accepts writes. */
|
|
46
|
+
writable: boolean
|
|
47
|
+
/** Whether the form holds edits that a save would write. */
|
|
48
|
+
dirty: boolean
|
|
49
|
+
/** Whether any staged draft is invalid, which blocks the save. */
|
|
50
|
+
invalid: boolean
|
|
51
|
+
/** Whether a save is crossing the wire. */
|
|
52
|
+
saving: boolean
|
|
53
|
+
/** Whether the last save did not land as staged; cleared by the next edit or save. */
|
|
54
|
+
failed: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The write actions the card's slot entry injects. */
|
|
58
|
+
export interface CardActions {
|
|
59
|
+
/** Stage draft text for one field. */
|
|
60
|
+
edit: (field: string, text: string) => void
|
|
61
|
+
/** Stage a clear, so saving lets the field re-inherit the composition layer. */
|
|
62
|
+
resetField: (field: string) => void
|
|
63
|
+
/** Write every staged edit, then re-seed from what the Host accepted. */
|
|
64
|
+
save: () => void
|
|
65
|
+
/** Drop every staged edit. */
|
|
66
|
+
discard: () => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One field's staged edit. */
|
|
70
|
+
interface StagedEdit {
|
|
71
|
+
/** Draft text the control renders. */
|
|
72
|
+
text: string
|
|
73
|
+
/** True when this edit clears the field whatever text it shows. */
|
|
74
|
+
clear: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One staged edit resolved into the write a save performs. */
|
|
78
|
+
interface PlannedWrite {
|
|
79
|
+
/** Field this entry writes. */
|
|
80
|
+
field: string
|
|
81
|
+
/** Perform the write and report whether the Host holds the staged value afterwards. */
|
|
82
|
+
run: (() => Promise<boolean>) | undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A whole-number field. An empty draft clears the field; any other draft that is not a finite number blocks the save. */
|
|
86
|
+
export function numberField(field: string): FieldSpec {
|
|
87
|
+
return {
|
|
88
|
+
field,
|
|
89
|
+
format: value => typeof value === 'number' ? String(value) : '',
|
|
90
|
+
parse: (text) => {
|
|
91
|
+
const trimmed = text.trim()
|
|
92
|
+
if (trimmed === '') return { kind: 'clear' }
|
|
93
|
+
const parsed = Number(trimmed)
|
|
94
|
+
return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A free-text field. An empty draft clears the field. */
|
|
100
|
+
export function textField(field: string): FieldSpec {
|
|
101
|
+
return {
|
|
102
|
+
field,
|
|
103
|
+
format: value => typeof value === 'string' ? value : '',
|
|
104
|
+
parse: (text) => {
|
|
105
|
+
const trimmed = text.trim()
|
|
106
|
+
return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A boolean field, edited through true/false draft text. */
|
|
112
|
+
export function booleanField(field: string): FieldSpec {
|
|
113
|
+
return {
|
|
114
|
+
field,
|
|
115
|
+
format: value => typeof value === 'boolean' ? String(value) : '',
|
|
116
|
+
parse: (text) => {
|
|
117
|
+
if (text === 'true') return { kind: 'set', value: true }
|
|
118
|
+
if (text === 'false') return { kind: 'set', value: false }
|
|
119
|
+
return undefined
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Stages one card's edits over one settings namespace and writes them on save.
|
|
126
|
+
*
|
|
127
|
+
* The Host is the only authority on whether a value was accepted — its
|
|
128
|
+
* validators own the constraints no schema can express — so the outcome is
|
|
129
|
+
* read back from the section rather than predicted here. A save that did not
|
|
130
|
+
* land keeps its drafts, so the user can correct them instead of retyping.
|
|
131
|
+
*/
|
|
132
|
+
export class CardForm<T> {
|
|
133
|
+
private readonly specs: Map<string, FieldSpec>
|
|
134
|
+
private readonly staged = new Map<string, StagedEdit>()
|
|
135
|
+
private readonly listeners = new Set<() => void>()
|
|
136
|
+
private saving = false
|
|
137
|
+
private failed = false
|
|
138
|
+
|
|
139
|
+
/** @param scope - the bound settings scope for this card's namespace. */
|
|
140
|
+
constructor(
|
|
141
|
+
private readonly scope: SettingsScope<T>,
|
|
142
|
+
specs: FieldSpec[],
|
|
143
|
+
) {
|
|
144
|
+
this.specs = new Map(specs.map(spec => [spec.field, spec]))
|
|
145
|
+
scope.subscribe(() => { this.publish() })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
|
|
149
|
+
bind<S>(project: () => S): SnapshotStore<S> {
|
|
150
|
+
const store = createSnapshotStore(project())
|
|
151
|
+
this.listeners.add(() => { store.set(project()) })
|
|
152
|
+
return store
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Read the card-level state: what the Host serves, and what a save would do. */
|
|
156
|
+
shell(): CardShell {
|
|
157
|
+
const snapshot = this.scope.getSnapshot()
|
|
158
|
+
const plan = this.plan()
|
|
159
|
+
return {
|
|
160
|
+
available: snapshot.status === 'ready',
|
|
161
|
+
writable: snapshot.writable,
|
|
162
|
+
dirty: plan.length > 0,
|
|
163
|
+
invalid: plan.some(item => item.run === undefined),
|
|
164
|
+
saving: this.saving,
|
|
165
|
+
failed: this.failed,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Read one field's state from the effective section and its staged draft. */
|
|
170
|
+
field(field: string): FieldState {
|
|
171
|
+
const spec = this.specOf(field)
|
|
172
|
+
const staged = this.staged.get(field)
|
|
173
|
+
if (staged === undefined) {
|
|
174
|
+
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
|
|
175
|
+
}
|
|
176
|
+
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
|
|
177
|
+
return {
|
|
178
|
+
text: staged.text,
|
|
179
|
+
overridden: write?.kind === 'set',
|
|
180
|
+
invalid: write === undefined,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The actions the card's slot registration injects. */
|
|
185
|
+
actions(): CardActions {
|
|
186
|
+
return {
|
|
187
|
+
edit: (field, text) => { this.stage(field, { text, clear: false }) },
|
|
188
|
+
resetField: (field) => {
|
|
189
|
+
this.stage(field, { text: this.specOf(field).format(this.baseValue(field)), clear: true })
|
|
190
|
+
},
|
|
191
|
+
save: () => { void this.save() },
|
|
192
|
+
discard: () => {
|
|
193
|
+
if (this.staged.size === 0 && !this.failed) return
|
|
194
|
+
this.staged.clear()
|
|
195
|
+
this.failed = false
|
|
196
|
+
this.publish()
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Write every staged edit, then re-seed from what the Host accepted.
|
|
203
|
+
* @returns settlement after every write and the read-back.
|
|
204
|
+
*/
|
|
205
|
+
async save(): Promise<void> {
|
|
206
|
+
const plan = this.plan()
|
|
207
|
+
const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
|
|
208
|
+
if (plan.length === 0 || this.saving || writes.length !== plan.length) return
|
|
209
|
+
this.saving = true
|
|
210
|
+
this.failed = false
|
|
211
|
+
this.publish()
|
|
212
|
+
let landed = true
|
|
213
|
+
for (const write of writes) {
|
|
214
|
+
landed = await write() && landed
|
|
215
|
+
}
|
|
216
|
+
if (landed) this.staged.clear()
|
|
217
|
+
this.saving = false
|
|
218
|
+
this.failed = !landed
|
|
219
|
+
this.publish()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Every staged edit a save would write. An entry whose draft is not a value
|
|
224
|
+
* its field accepts carries no write: the form is still dirty, and the save
|
|
225
|
+
* refuses rather than dropping the edit. A staged edit that matches the
|
|
226
|
+
* effective section is not a write at all.
|
|
227
|
+
* @returns the planned writes, in the order the fields were staged.
|
|
228
|
+
*/
|
|
229
|
+
private plan(): PlannedWrite[] {
|
|
230
|
+
const plan: PlannedWrite[] = []
|
|
231
|
+
for (const [field, staged] of this.staged) {
|
|
232
|
+
const spec = this.specOf(field)
|
|
233
|
+
if (staged.clear) {
|
|
234
|
+
if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
if (staged.text === spec.format(this.sectionValue(field))) continue
|
|
238
|
+
const write = spec.parse(staged.text)
|
|
239
|
+
if (write === undefined) plan.push({ field, run: undefined })
|
|
240
|
+
else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
|
|
241
|
+
else plan.push({ field, run: () => this.store(field, write.value) })
|
|
242
|
+
}
|
|
243
|
+
return plan
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async clear(field: string): Promise<boolean> {
|
|
247
|
+
await this.scope.unset(field)
|
|
248
|
+
return !this.stored(field)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private async store(field: string, value: unknown): Promise<boolean> {
|
|
252
|
+
await this.scope.set(field, value)
|
|
253
|
+
return this.userLayer()?.[field] === value
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private stage(field: string, edit: StagedEdit): void {
|
|
257
|
+
this.staged.set(field, edit)
|
|
258
|
+
this.failed = false
|
|
259
|
+
this.publish()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private specOf(field: string): FieldSpec {
|
|
263
|
+
const spec = this.specs.get(field)
|
|
264
|
+
// Every call site names a field this card declared; a missing one is a
|
|
265
|
+
// wiring mistake that must not degrade into a silently inert control.
|
|
266
|
+
if (spec === undefined) throw new Error(`settings card has no field ${field}`)
|
|
267
|
+
return spec
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private snapshotOf(): SettingsScopeSnapshot<T> {
|
|
271
|
+
return this.scope.getSnapshot()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private sectionValue(field: string): unknown {
|
|
275
|
+
return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private baseValue(field: string): unknown {
|
|
279
|
+
return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private userLayer(): Record<string, unknown> | undefined {
|
|
283
|
+
return this.snapshotOf().user as Record<string, unknown> | undefined
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private stored(field: string): boolean {
|
|
287
|
+
const user = this.userLayer()
|
|
288
|
+
return user !== undefined && Object.hasOwn(user, field)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private publish(): void {
|
|
292
|
+
for (const listener of this.listeners) listener()
|
|
293
|
+
}
|
|
294
|
+
}
|
package/src/gate.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `api/gate` listener: application-level access control layered on top
|
|
3
|
+
* of the transport fence (the fence is Host/Origin based and explicitly not
|
|
4
|
+
* an authentication layer — packages/client/connection documents this
|
|
5
|
+
* event as the sanctioned seam for pairing/revocation).
|
|
6
|
+
*
|
|
7
|
+
* Policy: loopback requests (the desktop) pass without a device identity;
|
|
8
|
+
* every non-loopback /api request must carry a live, non-revoked device
|
|
9
|
+
* cookie. This makes the QR the only way into a LAN-exposed dsh web and
|
|
10
|
+
* gives "停止" real teeth: revoked devices 403 on their next request,
|
|
11
|
+
* including the mux/SSE stream (which then dies on reconnect).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IncomingMessage } from 'node:http'
|
|
15
|
+
import type { PairingService } from './pairing.ts'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether a normalized URL hostname names the local loopback authority.
|
|
19
|
+
* Semantics mirror the connection package's internal predicate (localhost,
|
|
20
|
+
* IPv6 loopback, any IPv4 address in 127/8); it is reimplemented here because
|
|
21
|
+
* the connection package no longer exports it — the fence now lives inside
|
|
22
|
+
* the connection plugin, and external host plugins only need the
|
|
23
|
+
* classification, not the whole trust decision.
|
|
24
|
+
* @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
|
|
25
|
+
* @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
|
|
26
|
+
*/
|
|
27
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
28
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
29
|
+
const parts = hostname.split('.')
|
|
30
|
+
return parts.length === 4
|
|
31
|
+
&& parts[0] === '127'
|
|
32
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
36
|
+
export function isLoopbackAddress(address: string | undefined): boolean {
|
|
37
|
+
if (address === undefined) return false
|
|
38
|
+
const normalized = address.toLowerCase()
|
|
39
|
+
if (normalized === '::1') return true
|
|
40
|
+
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
|
|
41
|
+
return isIPv4Loopback(normalized)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
45
|
+
function isIPv4Loopback(v4: string): boolean {
|
|
46
|
+
const parts = v4.split('.')
|
|
47
|
+
return parts.length === 4
|
|
48
|
+
&& parts[0] === '127'
|
|
49
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read one cookie value from a Cookie header.
|
|
54
|
+
* @param header - the raw Cookie header value (or undefined).
|
|
55
|
+
* @param name - the cookie name.
|
|
56
|
+
* @returns the value, or undefined when absent.
|
|
57
|
+
*/
|
|
58
|
+
export function readCookie(header: string | undefined, name: string): string | undefined {
|
|
59
|
+
if (header === undefined) return undefined
|
|
60
|
+
for (const part of header.split(';')) {
|
|
61
|
+
const eq = part.indexOf('=')
|
|
62
|
+
if (eq < 0) continue
|
|
63
|
+
const key = part.slice(0, eq).trim()
|
|
64
|
+
if (key === name) return part.slice(eq + 1).trim()
|
|
65
|
+
}
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The effective Host hostname of a request.
|
|
71
|
+
* @param request - node HTTP request.
|
|
72
|
+
* @returns the normalized hostname, or undefined when unparsable.
|
|
73
|
+
*/
|
|
74
|
+
export function hostnameOf(request: IncomingMessage): string | undefined {
|
|
75
|
+
const host = request.headers.host
|
|
76
|
+
if (typeof host !== 'string') return undefined
|
|
77
|
+
try {
|
|
78
|
+
return new URL(`http://${host}`).hostname
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether a request comes from the desktop loopback client (loopback socket AND loopback Host). */
|
|
85
|
+
export function isLoopbackClient(request: IncomingMessage): boolean {
|
|
86
|
+
const hostname = hostnameOf(request)
|
|
87
|
+
if (hostname === undefined || !isLoopbackHostname(hostname)) return false
|
|
88
|
+
const socket = request.socket as { remoteAddress?: string } | undefined
|
|
89
|
+
return isLoopbackAddress(socket?.remoteAddress)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the api/gate listener for one pairing service.
|
|
94
|
+
* @param service - the pairing service.
|
|
95
|
+
* @param requirePairingForLan - when false, non-loopback requests pass
|
|
96
|
+
* without a device cookie (the feature then only manages tokens/status;
|
|
97
|
+
* revocation of paired devices still holds). A function is re-read per
|
|
98
|
+
* request, so a settings edit takes effect without a restart. Defaults to true.
|
|
99
|
+
* @param enabled - when false, every non-loopback request is vetoed while
|
|
100
|
+
* loopback stays available. A function is re-read per request so the fence
|
|
101
|
+
* stays mounted for the plugin lifetime and disabling the plugin cannot open
|
|
102
|
+
* a LAN-exposed /api. Defaults to true.
|
|
103
|
+
* @returns the cordis waterfall listener: call `next()` to delegate,
|
|
104
|
+
* return false (without calling it) to veto with 403.
|
|
105
|
+
*/
|
|
106
|
+
export function makeGateListener(
|
|
107
|
+
service: PairingService,
|
|
108
|
+
requirePairingForLan: boolean | (() => boolean) = true,
|
|
109
|
+
enabled: boolean | (() => boolean) = true,
|
|
110
|
+
): (request: IncomingMessage, method: string | undefined, next: () => boolean | Promise<boolean>) => boolean | Promise<boolean> {
|
|
111
|
+
return (request, _method, next) => {
|
|
112
|
+
if (isLoopbackClient(request)) return next()
|
|
113
|
+
const active = typeof enabled === 'function' ? enabled() : enabled
|
|
114
|
+
if (!active) return false
|
|
115
|
+
const require = typeof requirePairingForLan === 'function' ? requirePairingForLan() : requirePairingForLan
|
|
116
|
+
if (!require) return next()
|
|
117
|
+
const deviceId = readCookie(request.headers.cookie, service.config.cookieName)
|
|
118
|
+
if (deviceId === undefined) return false
|
|
119
|
+
return service.touchDevice(deviceId) ? next() : false
|
|
120
|
+
}
|
|
121
|
+
}
|