@modesto_ma/dsh-channels 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/lib/client.js +53 -37
- package/lib/client.js.map +1 -1
- package/lib/index.js +446 -665
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/analyze/contract.ts +8 -3
- package/src/analyze/launcher.ts +28 -5
- package/src/analyze/server.ts +60 -54
- package/src/client/api.ts +28 -30
- package/src/client/catalog.ts +1 -1
- package/src/client/panel/advanced-tab.tsx +1 -1
- package/src/config.ts +4 -13
- package/src/health.ts +0 -12
- package/src/host/agent.ts +24 -1
- package/src/index.ts +6 -4
- package/src/service.ts +18 -38
- package/src/session/manager.ts +4 -4
- package/src/session/request-policy.ts +64 -5
- package/src/settings/contract.ts +49 -34
- package/src/settings/routes.ts +159 -443
- package/src/settings/surface.ts +5 -6
- package/src/streamer.ts +3 -2
package/src/settings/routes.ts
CHANGED
|
@@ -1,35 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Web 设置面板的 HTTP 路由:
|
|
3
|
-
*
|
|
2
|
+
* Web 设置面板的 HTTP 路由:DSH `/api` 下的精确 Fetch 路由
|
|
3
|
+
* `/api/dsh-channels.<方法>`(读用 GET,写一律 POST)。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 准入快照与白名单/授权申请的写入口(与飞书卡片共用同一结算函数)。
|
|
5
|
+
* 路由注册进宿主 `connection.fetch`(headless profile 没有它时面板自然不存在,
|
|
6
|
+
* 飞书通道不受影响)。DSH 的 `/api` 路由先做 Host/Origin 校验和浏览器登录
|
|
7
|
+
* cookie 鉴权,只有通过的 owner 请求才会到达这里,未通过时由 DSH 直接返回
|
|
8
|
+
* 401 / 403。凭据值只走写路径,响应永远不包含它们。`catalog` 只读宿主
|
|
9
|
+
* LLM / preset roster 的枚举结果,用于面板的 provider → model → 推理档位
|
|
10
|
+
* 级联选择。`access` 提供准入快照与白名单/授权申请的写入口(与飞书卡片共用
|
|
11
|
+
* 同一结算函数)。
|
|
13
12
|
*
|
|
14
13
|
* @module dsh-channels/settings/routes
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
|
-
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
18
16
|
import type { HostContext } from '../dsh-types.ts'
|
|
19
17
|
import {
|
|
20
|
-
ACCESS_GROUPS_ROUTE,
|
|
21
|
-
ACCESS_REQUESTS_ROUTE,
|
|
22
18
|
ACCESS_ROUTE,
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
ADD_ACCESS_GROUP_ROUTE,
|
|
20
|
+
ADD_ACCESS_USER_ROUTE,
|
|
21
|
+
ADD_WIKI_USER_ROUTE,
|
|
25
22
|
CATALOG_ROUTE,
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
CLEAR_CONFIG_ROUTE,
|
|
24
|
+
CLEAR_CREDENTIAL_ROUTE,
|
|
28
25
|
PANEL_ROUTE,
|
|
29
26
|
PICK_DIRECTORY_ROUTE,
|
|
30
27
|
PROFILES_MAX_IDS,
|
|
31
28
|
PROFILES_ROUTE,
|
|
32
29
|
RECONNECT_ROUTE,
|
|
30
|
+
REMOVE_ACCESS_GROUP_ROUTE,
|
|
31
|
+
REMOVE_ACCESS_USER_ROUTE,
|
|
32
|
+
REMOVE_WIKI_USER_ROUTE,
|
|
33
|
+
RESOLVE_ACCESS_REQUEST_ROUTE,
|
|
34
|
+
SAVE_CONFIG_ROUTE,
|
|
35
|
+
SAVE_CREDENTIALS_ROUTE,
|
|
33
36
|
SESSION_ORIGIN_ROUTE,
|
|
34
37
|
STATUS_ROUTE,
|
|
35
38
|
} from './contract.ts'
|
|
@@ -41,29 +44,28 @@ import type {
|
|
|
41
44
|
} from './contract.ts'
|
|
42
45
|
import type { SettingsSurface } from './surface.ts'
|
|
43
46
|
|
|
44
|
-
/**
|
|
45
|
-
export interface
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}): () => void
|
|
47
|
+
/** One exact route on DSH's authenticated `/api` channel. */
|
|
48
|
+
export interface HostFetchRoute {
|
|
49
|
+
readonly path: string
|
|
50
|
+
readonly methods: readonly ('GET' | 'POST')[]
|
|
51
|
+
readonly requestBody: 'buffered'
|
|
52
|
+
readonly fetch: (request: Request) => Promise<Response>
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/**
|
|
54
|
-
* The narrow `ctx.connection` surface this module
|
|
55
|
-
* Host/Origin fence
|
|
56
|
+
* The narrow `ctx.connection` surface this module registers into. DSH applies
|
|
57
|
+
* its Host/Origin fence and login-cookie authentication before `fetch` runs.
|
|
56
58
|
*/
|
|
57
|
-
export interface
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
export interface HostConnection {
|
|
60
|
+
readonly fetch: {
|
|
61
|
+
/**
|
|
62
|
+
* @param route - exact path, methods, and handler.
|
|
63
|
+
* @returns asynchronous disposer removing the route.
|
|
64
|
+
*/
|
|
65
|
+
register(route: HostFetchRoute): () => Promise<void>
|
|
66
|
+
}
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
type WebHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
66
|
-
|
|
67
69
|
/** Request body cap: the panel payload is a handful of primitives. */
|
|
68
70
|
const MAX_BODY_BYTES = 64 * 1024
|
|
69
71
|
|
|
@@ -77,71 +79,30 @@ class HttpError extends Error {
|
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
function
|
|
81
|
-
return (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
|
85
|
-
const body = JSON.stringify(payload)
|
|
86
|
-
res.writeHead(status, {
|
|
87
|
-
'content-type': 'application/json; charset=utf-8',
|
|
88
|
-
'cache-control': 'no-store',
|
|
89
|
-
'content-length': Buffer.byteLength(body),
|
|
90
|
-
})
|
|
91
|
-
res.end(body)
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Reject non-allowed methods with 405 + Allow, returning false. */
|
|
95
|
-
function allowOnly(req: IncomingMessage, res: ServerResponse, allowed: readonly string[]): boolean {
|
|
96
|
-
const method = methodOf(req)
|
|
97
|
-
if (allowed.includes(method)) return true
|
|
98
|
-
const body = JSON.stringify({ ok: false, error: `方法 ${method} 不受支持` })
|
|
99
|
-
res.writeHead(405, {
|
|
100
|
-
allow: allowed.join(', '),
|
|
101
|
-
'content-type': 'application/json; charset=utf-8',
|
|
102
|
-
'cache-control': 'no-store',
|
|
103
|
-
'content-length': Buffer.byteLength(body),
|
|
82
|
+
function json(status: number, payload: unknown): Response {
|
|
83
|
+
return new Response(JSON.stringify(payload), {
|
|
84
|
+
status,
|
|
85
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
104
86
|
})
|
|
105
|
-
res.end(body)
|
|
106
|
-
return false
|
|
107
87
|
}
|
|
108
88
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const host = req.headers.host
|
|
118
|
-
if (host === undefined) return false
|
|
119
|
-
try {
|
|
120
|
-
return new URL(origin).host === host
|
|
121
|
-
} catch {
|
|
122
|
-
return false
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
127
|
-
const contentType = (req.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase()
|
|
128
|
-
if (contentType !== 'application/json') {
|
|
129
|
-
throw new HttpError(415, '请求体必须是 application/json')
|
|
130
|
-
}
|
|
131
|
-
const chunks: Buffer[] = []
|
|
132
|
-
let size = 0
|
|
133
|
-
for await (const chunk of req) {
|
|
134
|
-
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk as Buffer
|
|
135
|
-
size += buffer.length
|
|
136
|
-
if (size > MAX_BODY_BYTES) throw new HttpError(413, '请求体过大')
|
|
137
|
-
chunks.push(buffer)
|
|
138
|
-
}
|
|
139
|
-
if (size === 0) throw new HttpError(400, '请求体为空')
|
|
89
|
+
/** Parse a JSON-object body; malformed bodies throw {@link HttpError} (415/413/400). */
|
|
90
|
+
async function readJsonObject(request: Request): Promise<Record<string, unknown>> {
|
|
91
|
+
const contentType = (request.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase()
|
|
92
|
+
if (contentType !== 'application/json') throw new HttpError(415, '请求体必须是 application/json')
|
|
93
|
+
const text = await request.text()
|
|
94
|
+
if (Buffer.byteLength(text) > MAX_BODY_BYTES) throw new HttpError(413, '请求体过大')
|
|
95
|
+
if (text === '') throw new HttpError(400, '请求体为空')
|
|
96
|
+
let body: unknown
|
|
140
97
|
try {
|
|
141
|
-
|
|
98
|
+
body = JSON.parse(text)
|
|
142
99
|
} catch {
|
|
143
100
|
throw new HttpError(400, '请求体不是合法 JSON')
|
|
144
101
|
}
|
|
102
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
103
|
+
throw new HttpError(400, '请求体必须是 JSON 对象')
|
|
104
|
+
}
|
|
105
|
+
return body as Record<string, unknown>
|
|
145
106
|
}
|
|
146
107
|
|
|
147
108
|
/** Distinct non-empty entries of one comma-separated id query parameter. */
|
|
@@ -150,383 +111,138 @@ function idList(raw: string | null): string[] {
|
|
|
150
111
|
return [...new Set(raw.split(',').map(id => id.trim()).filter(id => id !== ''))]
|
|
151
112
|
}
|
|
152
113
|
|
|
153
|
-
/**
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
*/
|
|
157
|
-
async function readAccessBody(
|
|
158
|
-
req: IncomingMessage,
|
|
159
|
-
res: ServerResponse,
|
|
160
|
-
): Promise<Record<string, unknown> | undefined> {
|
|
161
|
-
let body: unknown
|
|
162
|
-
try {
|
|
163
|
-
body = await readJsonBody(req)
|
|
164
|
-
} catch (error) {
|
|
165
|
-
if (error instanceof HttpError) {
|
|
166
|
-
sendJson(res, error.status, { ok: false, error: error.message })
|
|
167
|
-
return undefined
|
|
168
|
-
}
|
|
169
|
-
throw error
|
|
170
|
-
}
|
|
171
|
-
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
172
|
-
sendJson(res, 400, { ok: false, error: '请求体必须是 JSON 对象' })
|
|
173
|
-
return undefined
|
|
174
|
-
}
|
|
175
|
-
return body as Record<string, unknown>
|
|
114
|
+
/** A write's answer: 200 on success, 400 for a refusal the body explains. */
|
|
115
|
+
function result(outcome: { ok: boolean }): Response {
|
|
116
|
+
return json(outcome.ok ? 200 : 400, outcome)
|
|
176
117
|
}
|
|
177
118
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
*/
|
|
182
|
-
function guardedBy(gate: HostRequestGate, handler: WebHandler): WebHandler {
|
|
183
|
-
return async (req, res) => {
|
|
184
|
-
const rejection = gate.requestRejection(req)
|
|
185
|
-
if (rejection !== undefined) {
|
|
186
|
-
sendJson(res, rejection, {
|
|
187
|
-
ok: false,
|
|
188
|
-
error: rejection === 401 ? '未登录 DSH,请从 DSH 启动链接打开页面' : '请求来源不受信任',
|
|
189
|
-
})
|
|
190
|
-
return
|
|
191
|
-
}
|
|
119
|
+
/** Turn thrown errors into JSON answers instead of a dropped request. */
|
|
120
|
+
function answered(handler: (request: Request) => Promise<Response>): (request: Request) => Promise<Response> {
|
|
121
|
+
return async (request) => {
|
|
192
122
|
try {
|
|
193
|
-
await handler(
|
|
123
|
+
return await handler(request)
|
|
194
124
|
} catch (error) {
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
return
|
|
198
|
-
}
|
|
199
|
-
const status = error instanceof HttpError ? error.status : 500
|
|
200
|
-
sendJson(res, status, {
|
|
201
|
-
ok: false,
|
|
202
|
-
error: error instanceof HttpError ? error.message : `内部错误:${messageOf(error)}`,
|
|
203
|
-
})
|
|
125
|
+
if (error instanceof HttpError) return json(error.status, { ok: false, error: error.message })
|
|
126
|
+
return json(500, { ok: false, error: `内部错误:${messageOf(error)}` })
|
|
204
127
|
}
|
|
205
128
|
}
|
|
206
129
|
}
|
|
207
130
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
)
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}),
|
|
233
|
-
server.register({
|
|
234
|
-
kind: 'exact',
|
|
235
|
-
path: PANEL_ROUTE,
|
|
236
|
-
handler: guarded(async (req, res) => {
|
|
237
|
-
if (!allowOnly(req, res, ['GET', 'HEAD'])) return
|
|
238
|
-
if (methodOf(req) === 'HEAD') {
|
|
239
|
-
res.writeHead(200, { 'cache-control': 'no-store' })
|
|
240
|
-
res.end()
|
|
241
|
-
return
|
|
242
|
-
}
|
|
243
|
-
sendJson(res, 200, await surface.panel())
|
|
244
|
-
}),
|
|
245
|
-
}),
|
|
246
|
-
server.register({
|
|
247
|
-
kind: 'exact',
|
|
248
|
-
path: CATALOG_ROUTE,
|
|
249
|
-
handler: guarded(async (req, res) => {
|
|
250
|
-
if (!allowOnly(req, res, ['GET', 'HEAD'])) return
|
|
251
|
-
if (methodOf(req) === 'HEAD') {
|
|
252
|
-
res.writeHead(200, { 'cache-control': 'no-store' })
|
|
253
|
-
res.end()
|
|
254
|
-
return
|
|
255
|
-
}
|
|
256
|
-
const url = new URL(req.url ?? '/', 'http://dsh.local')
|
|
257
|
-
sendJson(res, 200, await surface.catalogFor(
|
|
258
|
-
url.searchParams.get('provider') ?? undefined,
|
|
259
|
-
url.searchParams.get('model') ?? undefined,
|
|
260
|
-
))
|
|
261
|
-
}),
|
|
262
|
-
}),
|
|
263
|
-
server.register({
|
|
264
|
-
kind: 'exact',
|
|
265
|
-
path: SESSION_ORIGIN_ROUTE,
|
|
266
|
-
handler: guarded(async (req, res) => {
|
|
267
|
-
if (!allowOnly(req, res, ['GET', 'HEAD'])) return
|
|
268
|
-
if (methodOf(req) === 'HEAD') {
|
|
269
|
-
res.writeHead(200, { 'cache-control': 'no-store' })
|
|
270
|
-
res.end()
|
|
271
|
-
return
|
|
272
|
-
}
|
|
273
|
-
const sessionId = new URL(req.url ?? '/', 'http://dsh.local').searchParams.get('sessionId')?.trim()
|
|
274
|
-
if (sessionId === undefined || sessionId === '') {
|
|
275
|
-
sendJson(res, 400, { ok: false, error: '缺少 sessionId 参数' })
|
|
276
|
-
return
|
|
277
|
-
}
|
|
278
|
-
sendJson(res, 200, await surface.sessionOrigin(sessionId))
|
|
279
|
-
}),
|
|
131
|
+
/**
|
|
132
|
+
* Register the panel routes.
|
|
133
|
+
* @param connection - the host `connection` service.
|
|
134
|
+
* @param surface - the panel's business side.
|
|
135
|
+
* @returns disposer removing every route.
|
|
136
|
+
*/
|
|
137
|
+
export function registerSettingsRoutes(connection: HostConnection, surface: SettingsSurface): () => void {
|
|
138
|
+
const get = (path: string, handler: (url: URL) => Promise<Response>): HostFetchRoute => ({
|
|
139
|
+
path, methods: ['GET'], requestBody: 'buffered', fetch: answered(request => handler(new URL(request.url))),
|
|
140
|
+
})
|
|
141
|
+
const post = (path: string, handler: (request: Request) => Promise<Response>): HostFetchRoute => ({
|
|
142
|
+
path, methods: ['POST'], requestBody: 'buffered', fetch: answered(handler),
|
|
143
|
+
})
|
|
144
|
+
const routes: HostFetchRoute[] = [
|
|
145
|
+
get(STATUS_ROUTE, async () => json(200, await surface.status())),
|
|
146
|
+
get(PANEL_ROUTE, async () => json(200, await surface.panel())),
|
|
147
|
+
get(CATALOG_ROUTE, async url => json(200, await surface.catalogFor(
|
|
148
|
+
url.searchParams.get('provider') ?? undefined,
|
|
149
|
+
url.searchParams.get('model') ?? undefined,
|
|
150
|
+
))),
|
|
151
|
+
get(SESSION_ORIGIN_ROUTE, async (url) => {
|
|
152
|
+
const sessionId = url.searchParams.get('sessionId')?.trim()
|
|
153
|
+
if (sessionId === undefined || sessionId === '') return json(400, { ok: false, error: '缺少 sessionId 参数' })
|
|
154
|
+
return json(200, await surface.sessionOrigin(sessionId))
|
|
280
155
|
}),
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
res.end()
|
|
289
|
-
return
|
|
290
|
-
}
|
|
291
|
-
const params = new URL(req.url ?? '/', 'http://dsh.local').searchParams
|
|
292
|
-
const userIds = idList(params.get('users'))
|
|
293
|
-
const chatIds = idList(params.get('chats'))
|
|
294
|
-
if (userIds.length > PROFILES_MAX_IDS || chatIds.length > PROFILES_MAX_IDS) {
|
|
295
|
-
sendJson(res, 400, { ok: false, error: `每次最多查询 ${PROFILES_MAX_IDS} 个用户和 ${PROFILES_MAX_IDS} 个群` })
|
|
296
|
-
return
|
|
297
|
-
}
|
|
298
|
-
sendJson(res, 200, await surface.feishuProfiles(userIds, chatIds))
|
|
299
|
-
}),
|
|
156
|
+
get(PROFILES_ROUTE, async (url) => {
|
|
157
|
+
const userIds = idList(url.searchParams.get('users'))
|
|
158
|
+
const chatIds = idList(url.searchParams.get('chats'))
|
|
159
|
+
if (userIds.length > PROFILES_MAX_IDS || chatIds.length > PROFILES_MAX_IDS) {
|
|
160
|
+
return json(400, { ok: false, error: `每次最多查询 ${PROFILES_MAX_IDS} 个用户和 ${PROFILES_MAX_IDS} 个群` })
|
|
161
|
+
}
|
|
162
|
+
return json(200, await surface.feishuProfiles(userIds, chatIds))
|
|
300
163
|
}),
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
return
|
|
309
|
-
}
|
|
310
|
-
if (methodOf(req) === 'DELETE') {
|
|
311
|
-
const result = await surface.clear()
|
|
312
|
-
sendJson(res, result.ok ? 200 : 400, result)
|
|
313
|
-
return
|
|
314
|
-
}
|
|
315
|
-
let body: unknown
|
|
316
|
-
try {
|
|
317
|
-
body = await readJsonBody(req)
|
|
318
|
-
} catch (error) {
|
|
319
|
-
if (error instanceof HttpError) {
|
|
320
|
-
sendJson(res, error.status, { ok: false, error: error.message })
|
|
321
|
-
return
|
|
322
|
-
}
|
|
323
|
-
throw error
|
|
324
|
-
}
|
|
325
|
-
const values = (body as { values?: unknown } | null)?.values
|
|
326
|
-
if (typeof values !== 'object' || values === null || Array.isArray(values)) {
|
|
327
|
-
sendJson(res, 400, { ok: false, error: '请求体缺少 values 对象' })
|
|
328
|
-
return
|
|
329
|
-
}
|
|
330
|
-
const result = await surface.save(values as EditableValues)
|
|
331
|
-
sendJson(res, result.ok ? 200 : 400, result)
|
|
332
|
-
}),
|
|
164
|
+
get(ACCESS_ROUTE, async () => json(200, await surface.access())),
|
|
165
|
+
post(SAVE_CONFIG_ROUTE, async (request) => {
|
|
166
|
+
const values = (await readJsonObject(request)).values
|
|
167
|
+
if (typeof values !== 'object' || values === null || Array.isArray(values)) {
|
|
168
|
+
return json(400, { ok: false, error: '请求体缺少 values 对象' })
|
|
169
|
+
}
|
|
170
|
+
return result(await surface.save(values as EditableValues))
|
|
333
171
|
}),
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
let body: unknown
|
|
344
|
-
try {
|
|
345
|
-
body = await readJsonBody(req)
|
|
346
|
-
} catch (error) {
|
|
347
|
-
if (error instanceof HttpError) {
|
|
348
|
-
sendJson(res, error.status, { ok: false, error: error.message })
|
|
349
|
-
return
|
|
350
|
-
}
|
|
351
|
-
throw error
|
|
352
|
-
}
|
|
353
|
-
const payload = body as { appId?: unknown; appSecret?: unknown; analyzeToken?: unknown; field?: unknown } | null
|
|
354
|
-
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
355
|
-
sendJson(res, 400, { ok: false, error: '请求体必须是 JSON 对象' })
|
|
356
|
-
return
|
|
357
|
-
}
|
|
358
|
-
if (methodOf(req) === 'DELETE') {
|
|
359
|
-
const field = payload.field
|
|
360
|
-
if (field !== 'appId' && field !== 'appSecret' && field !== 'analyzeToken') {
|
|
361
|
-
sendJson(res, 400, { ok: false, error: 'field 必须是 appId、appSecret 或 analyzeToken' })
|
|
362
|
-
return
|
|
363
|
-
}
|
|
364
|
-
const result = await surface.clearCredential(field as CredentialField)
|
|
365
|
-
sendJson(res, result.ok ? 200 : 400, result)
|
|
366
|
-
return
|
|
367
|
-
}
|
|
368
|
-
const values: SaveCredentialsRequest = {}
|
|
369
|
-
if (payload.appId !== undefined) values.appId = payload.appId as string
|
|
370
|
-
if (payload.appSecret !== undefined) values.appSecret = payload.appSecret as string
|
|
371
|
-
if (payload.analyzeToken !== undefined) values.analyzeToken = payload.analyzeToken as string
|
|
372
|
-
const result = await surface.saveCredentials(values)
|
|
373
|
-
sendJson(res, result.ok ? 200 : 400, result)
|
|
374
|
-
}),
|
|
172
|
+
post(CLEAR_CONFIG_ROUTE, async () => result(await surface.clear())),
|
|
173
|
+
post(SAVE_CREDENTIALS_ROUTE, async (request) => {
|
|
174
|
+
const payload = await readJsonObject(request)
|
|
175
|
+
const values: SaveCredentialsRequest = {}
|
|
176
|
+
if (payload.appId !== undefined) values.appId = payload.appId as string
|
|
177
|
+
if (payload.appSecret !== undefined) values.appSecret = payload.appSecret as string
|
|
178
|
+
if (payload.analyzeToken !== undefined) values.analyzeToken = payload.analyzeToken as string
|
|
179
|
+
return result(await surface.saveCredentials(values))
|
|
375
180
|
}),
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
forbiddenWrite(res)
|
|
383
|
-
return
|
|
384
|
-
}
|
|
385
|
-
const result = await surface.reconnect()
|
|
386
|
-
// An incomplete configuration is a client-side state (the panel routes
|
|
387
|
-
// the user to the credentials form), not a server failure.
|
|
388
|
-
sendJson(res, result.ok ? 200 : (result.unconfigured === true ? 400 : 500), result)
|
|
389
|
-
}),
|
|
181
|
+
post(CLEAR_CREDENTIAL_ROUTE, async (request) => {
|
|
182
|
+
const field = (await readJsonObject(request)).field
|
|
183
|
+
if (field !== 'appId' && field !== 'appSecret' && field !== 'analyzeToken') {
|
|
184
|
+
return json(400, { ok: false, error: 'field 必须是 appId、appSecret 或 analyzeToken' })
|
|
185
|
+
}
|
|
186
|
+
return result(await surface.clearCredential(field as CredentialField))
|
|
390
187
|
}),
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (!isSameOriginRequest(req)) {
|
|
397
|
-
forbiddenWrite(res)
|
|
398
|
-
return
|
|
399
|
-
}
|
|
400
|
-
// The chooser is a local desktop modal: it can stay open far longer
|
|
401
|
-
// than a normal request, and the operator may close the page instead
|
|
402
|
-
// of the dialog. Abort the host picker when the response socket goes
|
|
403
|
-
// away so no orphaned modal outlives its panel.
|
|
404
|
-
const controller = new AbortController()
|
|
405
|
-
let answered = false
|
|
406
|
-
if (typeof res.on === 'function') {
|
|
407
|
-
res.on('close', () => {
|
|
408
|
-
if (!answered) controller.abort()
|
|
409
|
-
})
|
|
410
|
-
}
|
|
411
|
-
let path: string | null
|
|
412
|
-
try {
|
|
413
|
-
path = await surface.pickDirectory(controller.signal)
|
|
414
|
-
} catch (error) {
|
|
415
|
-
answered = true
|
|
416
|
-
// A request the browser already dropped has nobody left to answer.
|
|
417
|
-
if (controller.signal.aborted) {
|
|
418
|
-
res.end()
|
|
419
|
-
return
|
|
420
|
-
}
|
|
421
|
-
throw error
|
|
422
|
-
}
|
|
423
|
-
answered = true
|
|
424
|
-
const payload: PickDirectoryResponse = path === null
|
|
425
|
-
? { ok: true, canceled: true }
|
|
426
|
-
: { ok: true, path }
|
|
427
|
-
sendJson(res, 200, payload)
|
|
428
|
-
}),
|
|
188
|
+
post(RECONNECT_ROUTE, async () => {
|
|
189
|
+
const outcome = await surface.reconnect()
|
|
190
|
+
// An incomplete configuration is a client-side state (the panel routes
|
|
191
|
+
// the user to the credentials form), not a server failure.
|
|
192
|
+
return json(outcome.ok ? 200 : (outcome.unconfigured === true ? 400 : 500), outcome)
|
|
429
193
|
}),
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
res.end()
|
|
438
|
-
return
|
|
439
|
-
}
|
|
440
|
-
sendJson(res, 200, await surface.access())
|
|
441
|
-
}),
|
|
194
|
+
post(PICK_DIRECTORY_ROUTE, async (request) => {
|
|
195
|
+
// The chooser is a local desktop modal that can stay open far longer
|
|
196
|
+
// than a normal request; DSH aborts `request.signal` when the browser
|
|
197
|
+
// drops the request, which closes the host picker with it.
|
|
198
|
+
const path = await surface.pickDirectory(request.signal)
|
|
199
|
+
const payload: PickDirectoryResponse = path === null ? { ok: true, canceled: true } : { ok: true, path }
|
|
200
|
+
return json(200, payload)
|
|
442
201
|
}),
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
handler: guarded(async (req, res) => {
|
|
447
|
-
if (!allowOnly(req, res, ['POST', 'DELETE'])) return
|
|
448
|
-
if (!isSameOriginRequest(req)) {
|
|
449
|
-
forbiddenWrite(res)
|
|
450
|
-
return
|
|
451
|
-
}
|
|
452
|
-
const payload = await readAccessBody(req, res)
|
|
453
|
-
if (payload === undefined) return
|
|
454
|
-
const result = methodOf(req) === 'DELETE'
|
|
455
|
-
? await surface.removeAccessGroup(payload.chatId)
|
|
456
|
-
: await surface.saveAccessGroup(payload.chatId, payload.name)
|
|
457
|
-
sendJson(res, result.ok ? 200 : 400, result)
|
|
458
|
-
}),
|
|
202
|
+
post(ADD_ACCESS_GROUP_ROUTE, async (request) => {
|
|
203
|
+
const payload = await readJsonObject(request)
|
|
204
|
+
return result(await surface.saveAccessGroup(payload.chatId, payload.name))
|
|
459
205
|
}),
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
if (!allowOnly(req, res, ['POST', 'DELETE'])) return
|
|
465
|
-
if (!isSameOriginRequest(req)) {
|
|
466
|
-
forbiddenWrite(res)
|
|
467
|
-
return
|
|
468
|
-
}
|
|
469
|
-
const payload = await readAccessBody(req, res)
|
|
470
|
-
if (payload === undefined) return
|
|
471
|
-
const result = methodOf(req) === 'DELETE'
|
|
472
|
-
? await surface.removeAccessUser(payload.userId)
|
|
473
|
-
: await surface.saveAccessUser(payload.userId, payload.name)
|
|
474
|
-
sendJson(res, result.ok ? 200 : 400, result)
|
|
475
|
-
}),
|
|
206
|
+
post(REMOVE_ACCESS_GROUP_ROUTE, async request => result(await surface.removeAccessGroup((await readJsonObject(request)).chatId))),
|
|
207
|
+
post(ADD_ACCESS_USER_ROUTE, async (request) => {
|
|
208
|
+
const payload = await readJsonObject(request)
|
|
209
|
+
return result(await surface.saveAccessUser(payload.userId, payload.name))
|
|
476
210
|
}),
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
if (!allowOnly(req, res, ['POST', 'DELETE'])) return
|
|
482
|
-
if (!isSameOriginRequest(req)) {
|
|
483
|
-
forbiddenWrite(res)
|
|
484
|
-
return
|
|
485
|
-
}
|
|
486
|
-
const payload = await readAccessBody(req, res)
|
|
487
|
-
if (payload === undefined) return
|
|
488
|
-
const result = methodOf(req) === 'DELETE'
|
|
489
|
-
? await surface.removeWikiUser(payload.userId)
|
|
490
|
-
: await surface.saveWikiUser(payload.userId, payload.name)
|
|
491
|
-
sendJson(res, result.ok ? 200 : 400, result)
|
|
492
|
-
}),
|
|
211
|
+
post(REMOVE_ACCESS_USER_ROUTE, async request => result(await surface.removeAccessUser((await readJsonObject(request)).userId))),
|
|
212
|
+
post(ADD_WIKI_USER_ROUTE, async (request) => {
|
|
213
|
+
const payload = await readJsonObject(request)
|
|
214
|
+
return result(await surface.saveWikiUser(payload.userId, payload.name))
|
|
493
215
|
}),
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
if (!allowOnly(req, res, ['POST'])) return
|
|
499
|
-
if (!isSameOriginRequest(req)) {
|
|
500
|
-
forbiddenWrite(res)
|
|
501
|
-
return
|
|
502
|
-
}
|
|
503
|
-
const payload = await readAccessBody(req, res)
|
|
504
|
-
if (payload === undefined) return
|
|
505
|
-
const result = await surface.resolveAccessRequest(payload.token, payload.decision)
|
|
506
|
-
sendJson(res, result.ok ? 200 : 400, result)
|
|
507
|
-
}),
|
|
216
|
+
post(REMOVE_WIKI_USER_ROUTE, async request => result(await surface.removeWikiUser((await readJsonObject(request)).userId))),
|
|
217
|
+
post(RESOLVE_ACCESS_REQUEST_ROUTE, async (request) => {
|
|
218
|
+
const payload = await readJsonObject(request)
|
|
219
|
+
return result(await surface.resolveAccessRequest(payload.token, payload.decision))
|
|
508
220
|
}),
|
|
509
221
|
]
|
|
222
|
+
const disposers = routes.map(route => connection.fetch.register(route))
|
|
510
223
|
return () => {
|
|
511
|
-
for (const dispose of disposers)
|
|
224
|
+
for (const dispose of disposers) {
|
|
225
|
+
void dispose().catch(() => {
|
|
226
|
+
// The route is already gone when the host connection was disposed first.
|
|
227
|
+
})
|
|
228
|
+
}
|
|
512
229
|
}
|
|
513
230
|
}
|
|
514
231
|
|
|
515
232
|
/**
|
|
516
|
-
* Mount the routes once the host carries
|
|
517
|
-
* `
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
* the panel
|
|
233
|
+
* Mount the routes once the host carries the `connection` service. The
|
|
234
|
+
* optional `ctx.inject` keeps headless profiles from waiting on a service
|
|
235
|
+
* they will never have; embedded/test hosts that resolve services
|
|
236
|
+
* synchronously fall back to the direct attach path.
|
|
237
|
+
* @param ctx - the plugin context.
|
|
238
|
+
* @param surface - the panel's business side.
|
|
522
239
|
*/
|
|
523
240
|
export function mountSettingsRoutes(ctx: HostContext, surface: SettingsSurface): void {
|
|
524
|
-
const attach = (
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
() => registerSettingsRoutes(server, gate, surface),
|
|
241
|
+
const attach = (connectionCtx: HostContext): void => {
|
|
242
|
+
const connection = connectionCtx.get?.('connection') as HostConnection | undefined
|
|
243
|
+
if (connection?.fetch === undefined) return
|
|
244
|
+
connectionCtx.effect(
|
|
245
|
+
() => registerSettingsRoutes(connection, surface),
|
|
530
246
|
'dsh-channels: settings panel routes',
|
|
531
247
|
)
|
|
532
248
|
}
|
|
@@ -534,7 +250,7 @@ export function mountSettingsRoutes(ctx: HostContext, surface: SettingsSurface):
|
|
|
534
250
|
inject?: (dependencies: string[], callback: (child: HostContext) => void) => unknown
|
|
535
251
|
}).inject
|
|
536
252
|
if (typeof inject === 'function') {
|
|
537
|
-
inject.call(ctx, ['
|
|
253
|
+
inject.call(ctx, ['connection'], attach)
|
|
538
254
|
return
|
|
539
255
|
}
|
|
540
256
|
attach(ctx)
|