@jsbot-tellus/core 2.4.0 → 2.5.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/README.md +8 -5
- package/dist/app/cli.d.ts +10 -0
- package/dist/app/cli.d.ts.map +1 -0
- package/dist/app/cli.js +37 -0
- package/dist/app/cli.js.map +1 -0
- package/dist/app/runtime.d.ts +121 -0
- package/dist/app/runtime.d.ts.map +1 -0
- package/dist/app/runtime.js +296 -0
- package/dist/app/runtime.js.map +1 -0
- package/dist/app.js +63 -273
- package/dist/app.js.map +1 -1
- package/dist/core/dispatch/command.d.ts.map +1 -1
- package/dist/core/dispatch/command.js +5 -1
- package/dist/core/dispatch/command.js.map +1 -1
- package/dist/core/dispatch/router.d.ts.map +1 -1
- package/dist/core/dispatch/router.js +1 -1
- package/dist/core/dispatch/router.js.map +1 -1
- package/dist/core/plugin/command-params.d.ts +3 -36
- package/dist/core/plugin/command-params.d.ts.map +1 -1
- package/dist/core/plugin/command-params.js +2 -461
- package/dist/core/plugin/command-params.js.map +1 -1
- package/dist/core/plugin/install/paths.d.ts +7 -0
- package/dist/core/plugin/install/paths.d.ts.map +1 -0
- package/dist/core/plugin/install/paths.js +49 -0
- package/dist/core/plugin/install/paths.js.map +1 -0
- package/dist/core/plugin/install/state.d.ts +23 -0
- package/dist/core/plugin/install/state.d.ts.map +1 -0
- package/dist/core/plugin/install/state.js +129 -0
- package/dist/core/plugin/install/state.js.map +1 -0
- package/dist/core/plugin/install/validate-script.d.ts +3 -0
- package/dist/core/plugin/install/validate-script.d.ts.map +1 -0
- package/dist/core/plugin/install/validate-script.js +154 -0
- package/dist/core/plugin/install/validate-script.js.map +1 -0
- package/dist/core/plugin/install-dir.d.ts +3 -28
- package/dist/core/plugin/install-dir.d.ts.map +1 -1
- package/dist/core/plugin/install-dir.js +3 -326
- package/dist/core/plugin/install-dir.js.map +1 -1
- package/dist/core/plugin/installer/checks.d.ts +6 -0
- package/dist/core/plugin/installer/checks.d.ts.map +1 -0
- package/dist/core/plugin/installer/checks.js +99 -0
- package/dist/core/plugin/installer/checks.js.map +1 -0
- package/dist/core/plugin/installer/support.d.ts +37 -0
- package/dist/core/plugin/installer/support.d.ts.map +1 -0
- package/dist/core/plugin/installer/support.js +67 -0
- package/dist/core/plugin/installer/support.js.map +1 -0
- package/dist/core/plugin/installer/warnings.d.ts +5 -0
- package/dist/core/plugin/installer/warnings.d.ts.map +1 -0
- package/dist/core/plugin/installer/warnings.js +28 -0
- package/dist/core/plugin/installer/warnings.js.map +1 -0
- package/dist/core/plugin/installer.d.ts +4 -30
- package/dist/core/plugin/installer.d.ts.map +1 -1
- package/dist/core/plugin/installer.js +21 -202
- package/dist/core/plugin/installer.js.map +1 -1
- package/dist/core/plugin/params/compile.d.ts +3 -0
- package/dist/core/plugin/params/compile.d.ts.map +1 -0
- package/dist/core/plugin/params/compile.js +23 -0
- package/dist/core/plugin/params/compile.js.map +1 -0
- package/dist/core/plugin/params/declaration.d.ts +9 -0
- package/dist/core/plugin/params/declaration.d.ts.map +1 -0
- package/dist/core/plugin/params/declaration.js +223 -0
- package/dist/core/plugin/params/declaration.js.map +1 -0
- package/dist/core/plugin/params/input.d.ts +5 -0
- package/dist/core/plugin/params/input.d.ts.map +1 -0
- package/dist/core/plugin/params/input.js +181 -0
- package/dist/core/plugin/params/input.js.map +1 -0
- package/dist/core/plugin/params/reply.d.ts +3 -0
- package/dist/core/plugin/params/reply.d.ts.map +1 -0
- package/dist/core/plugin/params/reply.js +49 -0
- package/dist/core/plugin/params/reply.js.map +1 -0
- package/dist/core/plugin/params/type-level.d.ts +23 -0
- package/dist/core/plugin/params/type-level.d.ts.map +1 -0
- package/dist/core/plugin/params/type-level.js +2 -0
- package/dist/core/plugin/params/type-level.js.map +1 -0
- package/dist/core/plugin/params/types.d.ts +28 -0
- package/dist/core/plugin/params/types.d.ts.map +1 -0
- package/dist/core/plugin/params/types.js +12 -0
- package/dist/core/plugin/params/types.js.map +1 -0
- package/dist/core/plugin/params/values.d.ts +6 -0
- package/dist/core/plugin/params/values.d.ts.map +1 -0
- package/dist/core/plugin/params/values.js +108 -0
- package/dist/core/plugin/params/values.js.map +1 -0
- package/dist/core/web/api.d.ts +2 -14
- package/dist/core/web/api.d.ts.map +1 -1
- package/dist/core/web/api.js +8 -500
- package/dist/core/web/api.js.map +1 -1
- package/dist/core/web/routes/actions.d.ts +4 -0
- package/dist/core/web/routes/actions.d.ts.map +1 -0
- package/dist/core/web/routes/actions.js +33 -0
- package/dist/core/web/routes/actions.js.map +1 -0
- package/dist/core/web/routes/config.d.ts +5 -0
- package/dist/core/web/routes/config.d.ts.map +1 -0
- package/dist/core/web/routes/config.js +30 -0
- package/dist/core/web/routes/config.js.map +1 -0
- package/dist/core/web/routes/kv.d.ts +6 -0
- package/dist/core/web/routes/kv.d.ts.map +1 -0
- package/dist/core/web/routes/kv.js +80 -0
- package/dist/core/web/routes/kv.js.map +1 -0
- package/dist/core/web/routes/plugins.d.ts +5 -0
- package/dist/core/web/routes/plugins.d.ts.map +1 -0
- package/dist/core/web/routes/plugins.js +98 -0
- package/dist/core/web/routes/plugins.js.map +1 -0
- package/dist/core/web/routes/sessions.d.ts +8 -0
- package/dist/core/web/routes/sessions.d.ts.map +1 -0
- package/dist/core/web/routes/sessions.js +130 -0
- package/dist/core/web/routes/sessions.js.map +1 -0
- package/dist/core/web/routes/shared.d.ts +18 -0
- package/dist/core/web/routes/shared.d.ts.map +1 -0
- package/dist/core/web/routes/shared.js +19 -0
- package/dist/core/web/routes/shared.js.map +1 -0
- package/dist/core/web/routes/status.d.ts +6 -0
- package/dist/core/web/routes/status.d.ts.map +1 -0
- package/dist/core/web/routes/status.js +132 -0
- package/dist/core/web/routes/status.js.map +1 -0
- package/dist/core/web/server/auth.d.ts +17 -0
- package/dist/core/web/server/auth.d.ts.map +1 -0
- package/dist/core/web/server/auth.js +68 -0
- package/dist/core/web/server/auth.js.map +1 -0
- package/dist/core/web/server/csp.d.ts +11 -0
- package/dist/core/web/server/csp.d.ts.map +1 -0
- package/dist/core/web/server/csp.js +31 -0
- package/dist/core/web/server/csp.js.map +1 -0
- package/dist/core/web/server/request.d.ts +6 -0
- package/dist/core/web/server/request.d.ts.map +1 -0
- package/dist/core/web/server/request.js +33 -0
- package/dist/core/web/server/request.js.map +1 -0
- package/dist/core/web/server/security.d.ts +4 -0
- package/dist/core/web/server/security.d.ts.map +1 -0
- package/dist/core/web/server/security.js +12 -0
- package/dist/core/web/server/security.js.map +1 -0
- package/dist/core/web/server/sse.d.ts +18 -0
- package/dist/core/web/server/sse.d.ts.map +1 -0
- package/dist/core/web/server/sse.js +49 -0
- package/dist/core/web/server/sse.js.map +1 -0
- package/dist/core/web/server/static.d.ts +16 -0
- package/dist/core/web/server/static.d.ts.map +1 -0
- package/dist/core/web/server/static.js +80 -0
- package/dist/core/web/server/static.js.map +1 -0
- package/dist/core/web/server.d.ts +0 -7
- package/dist/core/web/server.d.ts.map +1 -1
- package/dist/core/web/server.js +41 -263
- package/dist/core/web/server.js.map +1 -1
- package/docs/architecture.md +28 -11
- package/docs/configuration.md +1 -1
- package/docs/migration.md +2 -2
- package/docs/plugin-api.md +3 -1
- package/docs/plugin-development.md +1 -1
- package/package.json +19 -17
- package/src/app/cli.ts +47 -0
- package/src/app/runtime.ts +522 -0
- package/src/app.ts +87 -395
- package/src/core/dispatch/command.ts +7 -1
- package/src/core/dispatch/router.ts +2 -1
- package/src/core/plugin/command-params.ts +8 -713
- package/src/core/plugin/install/paths.ts +72 -0
- package/src/core/plugin/install/state.ts +196 -0
- package/src/core/plugin/install/validate-script.ts +154 -0
- package/src/core/plugin/install-dir.ts +19 -427
- package/src/core/plugin/installer/checks.ts +137 -0
- package/src/core/plugin/installer/support.ts +120 -0
- package/src/core/plugin/installer/warnings.ts +52 -0
- package/src/core/plugin/installer.ts +52 -301
- package/src/core/plugin/params/compile.ts +41 -0
- package/src/core/plugin/params/declaration.ts +286 -0
- package/src/core/plugin/params/input.ts +254 -0
- package/src/core/plugin/params/reply.ts +59 -0
- package/src/core/plugin/params/type-level.ts +71 -0
- package/src/core/plugin/params/types.ts +49 -0
- package/src/core/plugin/params/values.ts +146 -0
- package/src/core/web/api.ts +33 -759
- package/src/core/web/routes/actions.ts +43 -0
- package/src/core/web/routes/config.ts +42 -0
- package/src/core/web/routes/kv.ts +122 -0
- package/src/core/web/routes/plugins.ts +151 -0
- package/src/core/web/routes/sessions.ts +188 -0
- package/src/core/web/routes/shared.ts +42 -0
- package/src/core/web/routes/status.ts +191 -0
- package/src/core/web/server/auth.ts +126 -0
- package/src/core/web/server/csp.ts +33 -0
- package/src/core/web/server/request.ts +38 -0
- package/src/core/web/server/security.ts +14 -0
- package/src/core/web/server/sse.ts +74 -0
- package/src/core/web/server/static.ts +112 -0
- package/src/core/web/server.ts +41 -383
package/src/core/web/server.ts
CHANGED
|
@@ -1,103 +1,32 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createServer,
|
|
3
3
|
type IncomingMessage,
|
|
4
|
-
type RequestListener,
|
|
5
4
|
type Server,
|
|
6
5
|
type ServerResponse,
|
|
7
6
|
} from 'node:http'
|
|
8
|
-
import { createReadStream, readFileSync } from 'node:fs'
|
|
9
|
-
import { stat } from 'node:fs/promises'
|
|
10
|
-
import { extname, join, resolve, sep } from 'node:path'
|
|
11
|
-
import { createHash } from 'node:crypto'
|
|
12
|
-
import { AuthManager, type AuthSession } from './auth.js'
|
|
13
7
|
import { handleApiRoute } from './api.js'
|
|
8
|
+
import { AuthManager } from './auth.js'
|
|
14
9
|
import {
|
|
15
10
|
assertCsrf,
|
|
16
11
|
assertSameOrigin,
|
|
17
|
-
clearCookie,
|
|
18
|
-
clientIp,
|
|
19
|
-
isImmutableAsset,
|
|
20
|
-
mimeType,
|
|
21
|
-
parseCookies,
|
|
22
|
-
readJsonBody,
|
|
23
12
|
respondWithError,
|
|
24
|
-
safeStringify,
|
|
25
|
-
sendError,
|
|
26
13
|
sendData,
|
|
27
|
-
serializeCookie,
|
|
28
|
-
withTimeout,
|
|
29
|
-
type URLSearchParamsLike,
|
|
30
14
|
} from './http.js'
|
|
31
15
|
import { HttpError } from './http.js'
|
|
16
|
+
import { authenticate, handleAuth } from './server/auth.js'
|
|
17
|
+
import { buildCsp, cspForStaticDir } from './server/csp.js'
|
|
18
|
+
import { parseSearch, pathnameOf, segmentsOf } from './server/request.js'
|
|
19
|
+
import { applySecurityHeaders } from './server/security.js'
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_MAX_SSE_CLIENTS,
|
|
22
|
+
openSse,
|
|
23
|
+
type SseConnection,
|
|
24
|
+
} from './server/sse.js'
|
|
25
|
+
import { FALLBACK_BODY, serveStatic } from './server/static.js'
|
|
32
26
|
import type { WebUiAddress, WebUiDeps, WebUiServerOptions } from './types.js'
|
|
33
27
|
|
|
34
|
-
const SESSION_COOKIE = 'jsbot_webui'
|
|
35
|
-
|
|
36
28
|
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
|
37
29
|
|
|
38
|
-
const SECURITY_HEADERS: Record<string, string> = {
|
|
39
|
-
'x-content-type-options': 'nosniff',
|
|
40
|
-
'x-frame-options': 'DENY',
|
|
41
|
-
'referrer-policy': 'no-referrer',
|
|
42
|
-
'cross-origin-opener-policy': 'same-origin',
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* CSP for the WebUI. webui/index.html ships a single authorized inline
|
|
47
|
-
* script (the no-flash appearance bootstrap for the first paint); its exact
|
|
48
|
-
* hash is derived from the built index.html on disk so editing the bootstrap
|
|
49
|
-
* only needs a rebuild, never a source edit. Every other inline script
|
|
50
|
-
* stays blocked.
|
|
51
|
-
*/
|
|
52
|
-
function buildCsp(scriptSrc: string): string {
|
|
53
|
-
return `default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src ${scriptSrc}; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'`
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Extracts the bootstrap script's sha256 from a built index.html. */
|
|
57
|
-
function cspForStaticDir(staticDir: string): string | null {
|
|
58
|
-
try {
|
|
59
|
-
const html = readFileSync(join(staticDir, 'index.html'), 'utf8')
|
|
60
|
-
const match = /<script>([\s\S]*?)<\/script>/.exec(html)
|
|
61
|
-
const bootstrap = match?.[1]
|
|
62
|
-
|
|
63
|
-
if (bootstrap === undefined) return null
|
|
64
|
-
|
|
65
|
-
const hash = createHash('sha256')
|
|
66
|
-
.update(bootstrap, 'utf8')
|
|
67
|
-
.digest('base64')
|
|
68
|
-
|
|
69
|
-
return buildCsp(`'self' 'sha256-${hash}'`)
|
|
70
|
-
} catch {
|
|
71
|
-
return null
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const SSE_HEARTBEAT_MS = 15_000
|
|
76
|
-
const DEFAULT_MAX_SSE_CLIENTS = 20
|
|
77
|
-
const SSE_TIMEOUT_MS = 60_000
|
|
78
|
-
|
|
79
|
-
interface SseConnection {
|
|
80
|
-
res: ServerResponse
|
|
81
|
-
close(): void
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
interface FallbackPageOptions {
|
|
85
|
-
listening: string
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const FALLBACK_BODY = (options: FallbackPageOptions): string => `<!doctype html>
|
|
89
|
-
<html lang="zh-CN"><head><meta charset="utf-8">
|
|
90
|
-
<title>JsBot WebUI</title>
|
|
91
|
-
<style>body{font-family:system-ui,sans-serif;max-width:36em;margin:4rem auto;padding:0 1rem;color:#333}</style>
|
|
92
|
-
</head><body>
|
|
93
|
-
<h1>WebUI 前端未构建</h1>
|
|
94
|
-
<p>管理 API 正常运行,但静态资源缺失。请在项目根目录执行:</p>
|
|
95
|
-
<pre><code>npm run build:webui</code></pre>
|
|
96
|
-
<p>然后重启进程,访问 <code>${options.listening}</code>。</p>
|
|
97
|
-
<p>API 仍可通过 <code>/api/health</code>、登录和各接口访问。</p>
|
|
98
|
-
</body></html>
|
|
99
|
-
`
|
|
100
|
-
|
|
101
30
|
function errorPayload(error: unknown): {
|
|
102
31
|
status: number
|
|
103
32
|
message: string
|
|
@@ -118,42 +47,6 @@ function errorPayload(error: unknown): {
|
|
|
118
47
|
}
|
|
119
48
|
}
|
|
120
49
|
|
|
121
|
-
function parseSearch(req: IncomingMessage): URLSearchParamsLike {
|
|
122
|
-
const index = (req.url ?? '').indexOf('?')
|
|
123
|
-
const query = index >= 0 ? (req.url ?? '').slice(index + 1) : ''
|
|
124
|
-
|
|
125
|
-
return new URLSearchParams(query)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function segmentsOf(pathname: string): string[] {
|
|
129
|
-
return pathname
|
|
130
|
-
.split('/')
|
|
131
|
-
.filter((segment) => segment.length > 0)
|
|
132
|
-
.map((segment) => {
|
|
133
|
-
try {
|
|
134
|
-
return decodeURIComponent(segment)
|
|
135
|
-
} catch {
|
|
136
|
-
return segment
|
|
137
|
-
}
|
|
138
|
-
})
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function pathnameOf(req: IncomingMessage): string {
|
|
142
|
-
const url = req.url ?? '/'
|
|
143
|
-
const index = url.indexOf('?')
|
|
144
|
-
const raw = index >= 0 ? url.slice(0, index) : url
|
|
145
|
-
|
|
146
|
-
if (raw.includes('%')) {
|
|
147
|
-
try {
|
|
148
|
-
return decodeURIComponent(raw)
|
|
149
|
-
} catch {
|
|
150
|
-
return raw
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return raw
|
|
155
|
-
}
|
|
156
|
-
|
|
157
50
|
export class WebUiServer {
|
|
158
51
|
private readonly deps: WebUiDeps
|
|
159
52
|
private readonly maxSseClients: number
|
|
@@ -267,9 +160,7 @@ export class WebUiServer {
|
|
|
267
160
|
}
|
|
268
161
|
|
|
269
162
|
private applySecurityHeaders(res: ServerResponse): void {
|
|
270
|
-
|
|
271
|
-
res.setHeader(key, value)
|
|
272
|
-
}
|
|
163
|
+
applySecurityHeaders(res)
|
|
273
164
|
|
|
274
165
|
if (this.cspCache === null) {
|
|
275
166
|
// Derived once per server instance from the built index.html.
|
|
@@ -301,7 +192,13 @@ export class WebUiServer {
|
|
|
301
192
|
}
|
|
302
193
|
|
|
303
194
|
if (!pathname.startsWith('/api/') && pathname !== '/api') {
|
|
304
|
-
await
|
|
195
|
+
await serveStatic({
|
|
196
|
+
req,
|
|
197
|
+
res,
|
|
198
|
+
pathname,
|
|
199
|
+
staticDir: this.deps.paths.staticDir,
|
|
200
|
+
fallbackBody: this.fallbackBody,
|
|
201
|
+
})
|
|
305
202
|
|
|
306
203
|
return
|
|
307
204
|
}
|
|
@@ -316,35 +213,47 @@ export class WebUiServer {
|
|
|
316
213
|
const segments = segmentsOf(pathname)
|
|
317
214
|
|
|
318
215
|
if (segments[1] === 'auth') {
|
|
319
|
-
await
|
|
216
|
+
await handleAuth({
|
|
217
|
+
req,
|
|
218
|
+
res,
|
|
219
|
+
action: segments[2],
|
|
220
|
+
auth: this.auth,
|
|
221
|
+
deps: this.deps,
|
|
222
|
+
bodyLimit: this.bodyLimit,
|
|
223
|
+
})
|
|
320
224
|
|
|
321
225
|
return
|
|
322
226
|
}
|
|
323
227
|
|
|
324
|
-
const session = this.
|
|
228
|
+
const session = authenticate(this.auth, req)
|
|
325
229
|
|
|
326
230
|
if (session === null) {
|
|
327
231
|
throw new HttpError(401, '未登录或会话已过期')
|
|
328
232
|
}
|
|
329
233
|
|
|
330
234
|
if (segments[1] === 'logs' && segments[2] === 'stream') {
|
|
331
|
-
|
|
235
|
+
openSse({
|
|
332
236
|
req,
|
|
333
237
|
res,
|
|
334
|
-
(listener) => this.deps.logs.subscribe(listener),
|
|
335
|
-
Date.now()
|
|
336
|
-
|
|
238
|
+
subscribe: (listener) => this.deps.logs.subscribe(listener),
|
|
239
|
+
openedAt: Date.now(),
|
|
240
|
+
maxClients: this.maxSseClients,
|
|
241
|
+
connections: this.sse,
|
|
242
|
+
})
|
|
337
243
|
|
|
338
244
|
return
|
|
339
245
|
}
|
|
340
246
|
|
|
341
247
|
if (segments[1] === 'events' && segments[2] === 'stream') {
|
|
342
|
-
|
|
248
|
+
openSse({
|
|
343
249
|
req,
|
|
344
250
|
res,
|
|
345
|
-
(listener) =>
|
|
346
|
-
|
|
347
|
-
|
|
251
|
+
subscribe: (listener) =>
|
|
252
|
+
this.deps.events.subscribe(listener),
|
|
253
|
+
openedAt: Date.now(),
|
|
254
|
+
maxClients: this.maxSseClients,
|
|
255
|
+
connections: this.sse,
|
|
256
|
+
})
|
|
348
257
|
|
|
349
258
|
return
|
|
350
259
|
}
|
|
@@ -367,255 +276,4 @@ export class WebUiServer {
|
|
|
367
276
|
respondWithError(res, error)
|
|
368
277
|
}
|
|
369
278
|
}
|
|
370
|
-
|
|
371
|
-
private authenticate(req: IncomingMessage): AuthSession | null {
|
|
372
|
-
const cookies = parseCookies(req.headers.cookie)
|
|
373
|
-
|
|
374
|
-
return this.auth.authenticate(cookies[SESSION_COOKIE])
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
private async handleAuth(
|
|
378
|
-
req: IncomingMessage,
|
|
379
|
-
res: ServerResponse,
|
|
380
|
-
action: string | undefined
|
|
381
|
-
): Promise<void> {
|
|
382
|
-
const method = req.method ?? 'GET'
|
|
383
|
-
|
|
384
|
-
if (action === 'login' && method === 'POST') {
|
|
385
|
-
const body = await readJsonBody(req, this.bodyLimit)
|
|
386
|
-
const value =
|
|
387
|
-
typeof body === 'object' && body !== null
|
|
388
|
-
? (body as Record<string, unknown>)
|
|
389
|
-
: {}
|
|
390
|
-
const ip = clientIp(req, this.deps.getWebUiConfig().trustProxy)
|
|
391
|
-
const result = await this.auth.login(
|
|
392
|
-
value['username'],
|
|
393
|
-
value['password'],
|
|
394
|
-
ip
|
|
395
|
-
)
|
|
396
|
-
|
|
397
|
-
if (!result.ok || !result.session) {
|
|
398
|
-
const headers: Record<string, string | undefined> = {}
|
|
399
|
-
|
|
400
|
-
if (result.retryAfterSeconds !== undefined) {
|
|
401
|
-
headers['retry-after'] = String(result.retryAfterSeconds)
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
sendError(
|
|
405
|
-
res,
|
|
406
|
-
result.status,
|
|
407
|
-
result.message ?? 'login failed',
|
|
408
|
-
undefined,
|
|
409
|
-
headers
|
|
410
|
-
)
|
|
411
|
-
|
|
412
|
-
return
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
res.setHeader(
|
|
416
|
-
'set-cookie',
|
|
417
|
-
serializeCookie(SESSION_COOKIE, result.session.token, {
|
|
418
|
-
maxAgeSeconds:
|
|
419
|
-
this.deps.getWebUiConfig().sessionTtlHours * 3600,
|
|
420
|
-
secure: this.isSecure(req),
|
|
421
|
-
})
|
|
422
|
-
)
|
|
423
|
-
sendData(res, result.status, {
|
|
424
|
-
username: result.session.username,
|
|
425
|
-
expiresAt: result.session.expiresAt,
|
|
426
|
-
})
|
|
427
|
-
|
|
428
|
-
return
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
if (action === 'logout' && method === 'POST') {
|
|
432
|
-
const session = this.authenticate(req)
|
|
433
|
-
|
|
434
|
-
this.auth.logout(this.tokenOf(req))
|
|
435
|
-
this.deps.logger.info(
|
|
436
|
-
`webui logout: ${session?.username ?? 'unknown'}`
|
|
437
|
-
)
|
|
438
|
-
res.setHeader('set-cookie', clearCookie(SESSION_COOKIE))
|
|
439
|
-
sendData(res, 200, { ok: true })
|
|
440
|
-
|
|
441
|
-
return
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
if (action === 'me' && method === 'GET') {
|
|
445
|
-
const session = this.authenticate(req)
|
|
446
|
-
|
|
447
|
-
if (session === null) {
|
|
448
|
-
throw new HttpError(401, '未登录或会话已过期')
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
sendData(res, 200, {
|
|
452
|
-
username: session.username,
|
|
453
|
-
expiresAt: session.expiresAt,
|
|
454
|
-
})
|
|
455
|
-
|
|
456
|
-
return
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
throw new HttpError(
|
|
460
|
-
404,
|
|
461
|
-
`not found: ${method} /api/auth/${action ?? ''}`
|
|
462
|
-
)
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
private tokenOf(req: IncomingMessage): string | undefined {
|
|
466
|
-
const cookies = parseCookies(req.headers.cookie)
|
|
467
|
-
|
|
468
|
-
return cookies[SESSION_COOKIE]
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
private isSecure(req: IncomingMessage): boolean {
|
|
472
|
-
if (!this.deps.getWebUiConfig().trustProxy) return false
|
|
473
|
-
|
|
474
|
-
const proto = req.headers['x-forwarded-proto']
|
|
475
|
-
|
|
476
|
-
return (
|
|
477
|
-
typeof proto === 'string' && proto.split(',')[0]?.trim() === 'https'
|
|
478
|
-
)
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
private openSse(
|
|
482
|
-
req: IncomingMessage,
|
|
483
|
-
res: ServerResponse,
|
|
484
|
-
subscribe: (listener: (entry: unknown) => void) => () => void,
|
|
485
|
-
openedAt: number
|
|
486
|
-
): void {
|
|
487
|
-
if (this.sse.size >= this.maxSseClients) {
|
|
488
|
-
throw new HttpError(503, '实时连接数量已达上限')
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
res.writeHead(200, {
|
|
492
|
-
'content-type': 'text/event-stream; charset=utf-8',
|
|
493
|
-
'cache-control': 'no-store, no-transform',
|
|
494
|
-
connection: 'keep-alive',
|
|
495
|
-
})
|
|
496
|
-
res.write(':ok\n\n')
|
|
497
|
-
|
|
498
|
-
const connection: SseConnection = {
|
|
499
|
-
res,
|
|
500
|
-
close: () => {
|
|
501
|
-
unsubscribe()
|
|
502
|
-
clearInterval(heartbeat)
|
|
503
|
-
this.sse.delete(connection)
|
|
504
|
-
|
|
505
|
-
try {
|
|
506
|
-
res.end()
|
|
507
|
-
} catch {
|
|
508
|
-
// Already closed.
|
|
509
|
-
}
|
|
510
|
-
},
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
let closing = false
|
|
514
|
-
|
|
515
|
-
const unsubscribe = subscribe((entry) => {
|
|
516
|
-
if (closing) return
|
|
517
|
-
|
|
518
|
-
res.write(`data: ${safeStringify(entry)}\n\n`)
|
|
519
|
-
})
|
|
520
|
-
|
|
521
|
-
const heartbeat = setInterval(() => {
|
|
522
|
-
res.write(`:keepalive ${Date.now() - openedAt}\n\n`)
|
|
523
|
-
}, SSE_HEARTBEAT_MS)
|
|
524
|
-
|
|
525
|
-
req.on('close', () => {
|
|
526
|
-
if (closing) return
|
|
527
|
-
|
|
528
|
-
closing = true
|
|
529
|
-
|
|
530
|
-
unsubscribe()
|
|
531
|
-
clearInterval(heartbeat)
|
|
532
|
-
this.sse.delete(connection)
|
|
533
|
-
})
|
|
534
|
-
|
|
535
|
-
this.sse.add(connection)
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
private async serveStatic(
|
|
539
|
-
req: IncomingMessage,
|
|
540
|
-
res: ServerResponse,
|
|
541
|
-
pathname: string
|
|
542
|
-
): Promise<void> {
|
|
543
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
544
|
-
throw new HttpError(405, `method not allowed: ${req.method}`)
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
const staticDir = this.deps.paths.staticDir
|
|
548
|
-
|
|
549
|
-
if (staticDir === null) {
|
|
550
|
-
res.setHeader('content-type', 'text/html; charset=utf-8')
|
|
551
|
-
res.setHeader('cache-control', 'no-store')
|
|
552
|
-
res.writeHead(503)
|
|
553
|
-
res.end(this.fallbackBody)
|
|
554
|
-
|
|
555
|
-
return
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
const cleaned = pathname.replace(/\/+$/, '') || '/'
|
|
559
|
-
let filePath =
|
|
560
|
-
cleaned === '/'
|
|
561
|
-
? join(staticDir, 'index.html')
|
|
562
|
-
: resolve(staticDir, `.${cleaned}`)
|
|
563
|
-
|
|
564
|
-
if (!filePath.startsWith(staticDir + sep) && filePath !== staticDir) {
|
|
565
|
-
throw new HttpError(403, 'forbidden')
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
let fileStat = await this.statOrNull(filePath)
|
|
569
|
-
|
|
570
|
-
if (fileStat?.isDirectory()) {
|
|
571
|
-
filePath = join(filePath, 'index.html')
|
|
572
|
-
fileStat = await this.statOrNull(filePath)
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
if (fileStat === null) {
|
|
576
|
-
// SPA fallback: only routes without a file extension.
|
|
577
|
-
if (extname(cleaned) === '') {
|
|
578
|
-
filePath = join(staticDir, 'index.html')
|
|
579
|
-
fileStat = await this.statOrNull(filePath)
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
if (fileStat === null) throw new HttpError(404, 'not found')
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
const cacheControl = isImmutableAsset(cleaned)
|
|
586
|
-
? 'public, max-age=31536000, immutable'
|
|
587
|
-
: 'no-cache'
|
|
588
|
-
|
|
589
|
-
res.setHeader('content-type', mimeType(filePath))
|
|
590
|
-
res.setHeader('cache-control', cacheControl)
|
|
591
|
-
res.setHeader('content-length', String(fileStat.size))
|
|
592
|
-
res.writeHead(200)
|
|
593
|
-
|
|
594
|
-
if (req.method === 'HEAD') {
|
|
595
|
-
res.end()
|
|
596
|
-
|
|
597
|
-
return
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
const stream = createReadStream(filePath)
|
|
601
|
-
|
|
602
|
-
stream.on('error', () => {
|
|
603
|
-
try {
|
|
604
|
-
res.end()
|
|
605
|
-
} catch {
|
|
606
|
-
// Already closed.
|
|
607
|
-
}
|
|
608
|
-
})
|
|
609
|
-
stream.pipe(res)
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
private async statOrNull(
|
|
613
|
-
filePath: string
|
|
614
|
-
): Promise<Awaited<ReturnType<typeof stat>> | null> {
|
|
615
|
-
try {
|
|
616
|
-
return await withTimeout(stat(filePath), 1_000)
|
|
617
|
-
} catch {
|
|
618
|
-
return null
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
279
|
}
|