@jsbot-tellus/core 2.3.0 → 2.5.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 +19 -0
- package/bot.config.example.json +3 -0
- package/dist/app.js +215 -98
- package/dist/app.js.map +1 -1
- package/dist/core/client.d.ts +1 -0
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/client.js +3 -0
- package/dist/core/client.js.map +1 -1
- package/dist/core/config/schema.d.ts +19 -0
- package/dist/core/config/schema.d.ts.map +1 -1
- package/dist/core/config/schema.js +37 -0
- package/dist/core/config/schema.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 +3 -0
- package/dist/core/dispatch/router.d.ts.map +1 -1
- package/dist/core/dispatch/router.js +14 -1
- package/dist/core/dispatch/router.js.map +1 -1
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/kernel.d.ts +8 -0
- package/dist/core/kernel.d.ts.map +1 -1
- package/dist/core/kernel.js +17 -0
- package/dist/core/kernel.js.map +1 -1
- package/dist/core/logging/log-buffer.d.ts +27 -0
- package/dist/core/logging/log-buffer.d.ts.map +1 -0
- package/dist/core/logging/log-buffer.js +71 -0
- package/dist/core/logging/log-buffer.js.map +1 -0
- package/dist/core/logging/logger.d.ts +3 -0
- package/dist/core/logging/logger.d.ts.map +1 -1
- package/dist/core/logging/logger.js +6 -3
- package/dist/core/logging/logger.js.map +1 -1
- package/dist/core/plugin/command-params.d.ts +4 -3
- package/dist/core/plugin/command-params.d.ts.map +1 -1
- package/dist/core/plugin/command-params.js +122 -3
- package/dist/core/plugin/command-params.js.map +1 -1
- package/dist/core/storage/session.d.ts +15 -0
- package/dist/core/storage/session.d.ts.map +1 -1
- package/dist/core/storage/session.js +55 -0
- package/dist/core/storage/session.js.map +1 -1
- package/dist/core/web/api.d.ts +16 -0
- package/dist/core/web/api.d.ts.map +1 -0
- package/dist/core/web/api.js +617 -0
- package/dist/core/web/api.js.map +1 -0
- package/dist/core/web/auth.d.ts +43 -0
- package/dist/core/web/auth.d.ts.map +1 -0
- package/dist/core/web/auth.js +161 -0
- package/dist/core/web/auth.js.map +1 -0
- package/dist/core/web/config-store.d.ts +30 -0
- package/dist/core/web/config-store.d.ts.map +1 -0
- package/dist/core/web/config-store.js +216 -0
- package/dist/core/web/config-store.js.map +1 -0
- package/dist/core/web/event-feed.d.ts +26 -0
- package/dist/core/web/event-feed.d.ts.map +1 -0
- package/dist/core/web/event-feed.js +107 -0
- package/dist/core/web/event-feed.js.map +1 -0
- package/dist/core/web/http.d.ts +39 -0
- package/dist/core/web/http.d.ts.map +1 -0
- package/dist/core/web/http.js +233 -0
- package/dist/core/web/http.js.map +1 -0
- package/dist/core/web/index.d.ts +11 -0
- package/dist/core/web/index.d.ts.map +1 -0
- package/dist/core/web/index.js +6 -0
- package/dist/core/web/index.js.map +1 -0
- package/dist/core/web/server.d.ts +30 -0
- package/dist/core/web/server.d.ts.map +1 -0
- package/dist/core/web/server.js +437 -0
- package/dist/core/web/server.js.map +1 -0
- package/dist/core/web/types.d.ts +84 -0
- package/dist/core/web/types.d.ts.map +1 -0
- package/dist/core/web/types.js +2 -0
- package/dist/core/web/types.js.map +1 -0
- package/dist/webui/assets/ActionsView-BDuXBWG1.js +1 -0
- package/dist/webui/assets/ActionsView-Ciib5lC6.css +1 -0
- package/dist/webui/assets/ConfigView-B3Gv2uRG.js +1 -0
- package/dist/webui/assets/ConfigView-CrEpss2y.css +1 -0
- package/dist/webui/assets/DashboardView-DR6yCaqs.css +1 -0
- package/dist/webui/assets/DashboardView-n9LGPmQL.js +1 -0
- package/dist/webui/assets/EventsView-DhO8twWb.js +1 -0
- package/dist/webui/assets/EventsView-PMkYKxZ-.css +1 -0
- package/dist/webui/assets/KvView-BCz_dOVC.js +1 -0
- package/dist/webui/assets/KvView-CaMvbxUz.css +1 -0
- package/dist/webui/assets/LoginView-BVnFkwSW.css +1 -0
- package/dist/webui/assets/LoginView-Br3_AC5z.js +1 -0
- package/dist/webui/assets/LogsView-Ba_12frI.js +1 -0
- package/dist/webui/assets/LogsView-NR4L1-XY.css +1 -0
- package/dist/webui/assets/MainLayout-D2LTc57u.js +1 -0
- package/dist/webui/assets/MainLayout-glfVNbMz.css +1 -0
- package/dist/webui/assets/PluginsView-DL9OxGph.css +1 -0
- package/dist/webui/assets/PluginsView-vQKBSCU8.js +1 -0
- package/dist/webui/assets/SessionsView-DSl37qpo.js +1 -0
- package/dist/webui/assets/SessionsView-p-u3JGTV.css +1 -0
- package/dist/webui/assets/api-CNzxhTWe.js +3 -0
- package/dist/webui/assets/index-CUvpnClx.js +50 -0
- package/dist/webui/assets/index-CePlRZX6.css +1 -0
- package/dist/webui/index.html +110 -0
- package/docs/plugin-api.md +2 -0
- package/docs/webui.md +142 -0
- package/package.json +14 -3
- package/src/app.ts +286 -118
- package/src/core/client.ts +4 -0
- package/src/core/config/schema.ts +40 -0
- package/src/core/dispatch/command.ts +7 -1
- package/src/core/dispatch/router.ts +17 -1
- package/src/core/index.ts +3 -0
- package/src/core/kernel.ts +25 -1
- package/src/core/logging/log-buffer.ts +109 -0
- package/src/core/logging/logger.ts +7 -3
- package/src/core/plugin/command-params.ts +165 -7
- package/src/core/storage/session.ts +95 -0
- package/src/core/web/api.ts +932 -0
- package/src/core/web/auth.ts +245 -0
- package/src/core/web/config-store.ts +333 -0
- package/src/core/web/event-feed.ts +163 -0
- package/src/core/web/http.ts +340 -0
- package/src/core/web/index.ts +25 -0
- package/src/core/web/server.ts +621 -0
- package/src/core/web/types.ts +100 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createServer,
|
|
3
|
+
type IncomingMessage,
|
|
4
|
+
type RequestListener,
|
|
5
|
+
type Server,
|
|
6
|
+
type ServerResponse,
|
|
7
|
+
} 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
|
+
import { handleApiRoute } from './api.js'
|
|
14
|
+
import {
|
|
15
|
+
assertCsrf,
|
|
16
|
+
assertSameOrigin,
|
|
17
|
+
clearCookie,
|
|
18
|
+
clientIp,
|
|
19
|
+
isImmutableAsset,
|
|
20
|
+
mimeType,
|
|
21
|
+
parseCookies,
|
|
22
|
+
readJsonBody,
|
|
23
|
+
respondWithError,
|
|
24
|
+
safeStringify,
|
|
25
|
+
sendError,
|
|
26
|
+
sendData,
|
|
27
|
+
serializeCookie,
|
|
28
|
+
withTimeout,
|
|
29
|
+
type URLSearchParamsLike,
|
|
30
|
+
} from './http.js'
|
|
31
|
+
import { HttpError } from './http.js'
|
|
32
|
+
import type { WebUiAddress, WebUiDeps, WebUiServerOptions } from './types.js'
|
|
33
|
+
|
|
34
|
+
const SESSION_COOKIE = 'jsbot_webui'
|
|
35
|
+
|
|
36
|
+
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
|
37
|
+
|
|
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
|
+
function errorPayload(error: unknown): {
|
|
102
|
+
status: number
|
|
103
|
+
message: string
|
|
104
|
+
details: unknown
|
|
105
|
+
} {
|
|
106
|
+
if (error instanceof HttpError) {
|
|
107
|
+
return {
|
|
108
|
+
status: error.status,
|
|
109
|
+
message: error.message,
|
|
110
|
+
details: error.details,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
status: 500,
|
|
116
|
+
message: error instanceof Error ? error.message : 'internal error',
|
|
117
|
+
details: undefined,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
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
|
+
export class WebUiServer {
|
|
158
|
+
private readonly deps: WebUiDeps
|
|
159
|
+
private readonly maxSseClients: number
|
|
160
|
+
private readonly bodyLimit: number
|
|
161
|
+
private readonly loginMaxFailures: number | undefined
|
|
162
|
+
private readonly loginWindowMs: number | undefined
|
|
163
|
+
private readonly auth: AuthManager
|
|
164
|
+
private readonly sse = new Set<SseConnection>()
|
|
165
|
+
private readonly fallbackBody: string
|
|
166
|
+
|
|
167
|
+
private httpServer: Server | null = null
|
|
168
|
+
private listening: WebUiAddress | null = null
|
|
169
|
+
private cspCache: string | null = null
|
|
170
|
+
|
|
171
|
+
constructor(options: WebUiServerOptions) {
|
|
172
|
+
const { config, deps } = options
|
|
173
|
+
|
|
174
|
+
this.deps = deps
|
|
175
|
+
this.maxSseClients = options.maxSseClients ?? DEFAULT_MAX_SSE_CLIENTS
|
|
176
|
+
this.bodyLimit = options.bodyLimitBytes ?? 1_048_576
|
|
177
|
+
this.loginMaxFailures = options.loginMaxFailures
|
|
178
|
+
this.loginWindowMs = options.loginWindowMs
|
|
179
|
+
this.fallbackBody = FALLBACK_BODY({
|
|
180
|
+
listening: `http://${config.host}:${config.port}`,
|
|
181
|
+
})
|
|
182
|
+
this.auth = new AuthManager({
|
|
183
|
+
getConfig: () => deps.getWebUiConfig(),
|
|
184
|
+
...(options.loginMaxFailures !== undefined
|
|
185
|
+
? { maxFailures: options.loginMaxFailures }
|
|
186
|
+
: {}),
|
|
187
|
+
...(options.loginWindowMs !== undefined
|
|
188
|
+
? { windowMs: options.loginWindowMs }
|
|
189
|
+
: {}),
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
get address(): WebUiAddress | null {
|
|
194
|
+
return this.listening
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async start(
|
|
198
|
+
config: WebUiServerOptions['config']
|
|
199
|
+
): Promise<WebUiAddress | null> {
|
|
200
|
+
if (this.httpServer !== null) return this.listening
|
|
201
|
+
|
|
202
|
+
const server = createServer(this.handleRequest.bind(this))
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
await new Promise<void>((resolvePromise, reject) => {
|
|
206
|
+
server.once('error', reject)
|
|
207
|
+
server.listen(config.port, config.host, () => {
|
|
208
|
+
server.off('error', reject)
|
|
209
|
+
resolvePromise()
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const address = server.address()
|
|
214
|
+
|
|
215
|
+
if (address === null) {
|
|
216
|
+
this.listening = {
|
|
217
|
+
host: config.host,
|
|
218
|
+
port: config.port,
|
|
219
|
+
}
|
|
220
|
+
} else if (typeof address === 'string') {
|
|
221
|
+
this.listening = { host: address, port: config.port }
|
|
222
|
+
} else {
|
|
223
|
+
this.listening = {
|
|
224
|
+
host: config.host,
|
|
225
|
+
port: address.port,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.httpServer = server
|
|
230
|
+
this.deps.logger.info(
|
|
231
|
+
`webui listening on http://${config.host}:${this.listening.port}`
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return this.listening
|
|
235
|
+
} catch (error) {
|
|
236
|
+
this.deps.logger.error('webui listen failed', error)
|
|
237
|
+
server.close()
|
|
238
|
+
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
get requiresRestart(): boolean {
|
|
244
|
+
return this.httpServer !== null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async stop(): Promise<void> {
|
|
248
|
+
for (const connection of [...this.sse]) connection.close()
|
|
249
|
+
|
|
250
|
+
this.sse.clear()
|
|
251
|
+
this.auth.invalidateAll()
|
|
252
|
+
|
|
253
|
+
const server = this.httpServer
|
|
254
|
+
|
|
255
|
+
if (server === null) return
|
|
256
|
+
|
|
257
|
+
this.httpServer = null
|
|
258
|
+
this.listening = null
|
|
259
|
+
|
|
260
|
+
await new Promise<void>((resolvePromise) => {
|
|
261
|
+
server.close(() => resolvePromise())
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
invalidateSessions(): void {
|
|
266
|
+
this.auth.invalidateAll()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private applySecurityHeaders(res: ServerResponse): void {
|
|
270
|
+
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
|
|
271
|
+
res.setHeader(key, value)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (this.cspCache === null) {
|
|
275
|
+
// Derived once per server instance from the built index.html.
|
|
276
|
+
const staticDir = this.deps.paths.staticDir
|
|
277
|
+
|
|
278
|
+
this.cspCache =
|
|
279
|
+
staticDir !== null
|
|
280
|
+
? (cspForStaticDir(staticDir) ?? buildCsp("'self'"))
|
|
281
|
+
: buildCsp("'self'")
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
res.setHeader('content-security-policy', this.cspCache)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private async handleRequest(
|
|
288
|
+
req: IncomingMessage,
|
|
289
|
+
res: ServerResponse
|
|
290
|
+
): Promise<void> {
|
|
291
|
+
this.applySecurityHeaders(res)
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const pathname = pathnameOf(req)
|
|
295
|
+
|
|
296
|
+
if (pathname === '/api/health') {
|
|
297
|
+
res.setHeader('cache-control', 'no-store')
|
|
298
|
+
sendData(res, 200, { ok: true })
|
|
299
|
+
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!pathname.startsWith('/api/') && pathname !== '/api') {
|
|
304
|
+
await this.serveStatic(req, res, pathname)
|
|
305
|
+
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!WRITE_METHODS.has(req.method ?? 'GET')) {
|
|
310
|
+
assertSameOrigin(req, this.deps.getWebUiConfig().trustProxy)
|
|
311
|
+
} else {
|
|
312
|
+
assertSameOrigin(req, this.deps.getWebUiConfig().trustProxy)
|
|
313
|
+
assertCsrf(req)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const segments = segmentsOf(pathname)
|
|
317
|
+
|
|
318
|
+
if (segments[1] === 'auth') {
|
|
319
|
+
await this.handleAuth(req, res, segments[2])
|
|
320
|
+
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const session = this.authenticate(req)
|
|
325
|
+
|
|
326
|
+
if (session === null) {
|
|
327
|
+
throw new HttpError(401, '未登录或会话已过期')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (segments[1] === 'logs' && segments[2] === 'stream') {
|
|
331
|
+
this.openSse(
|
|
332
|
+
req,
|
|
333
|
+
res,
|
|
334
|
+
(listener) => this.deps.logs.subscribe(listener),
|
|
335
|
+
Date.now()
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (segments[1] === 'events' && segments[2] === 'stream') {
|
|
342
|
+
this.openSse(
|
|
343
|
+
req,
|
|
344
|
+
res,
|
|
345
|
+
(listener) => this.deps.events.subscribe(listener),
|
|
346
|
+
Date.now()
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const handled = await handleApiRoute({
|
|
353
|
+
deps: this.deps,
|
|
354
|
+
req,
|
|
355
|
+
res,
|
|
356
|
+
method: req.method ?? 'GET',
|
|
357
|
+
pathname,
|
|
358
|
+
segments,
|
|
359
|
+
search: parseSearch(req),
|
|
360
|
+
session,
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
if (handled) return
|
|
364
|
+
|
|
365
|
+
throw new HttpError(404, `not found: ${req.method} ${pathname}`)
|
|
366
|
+
} catch (error) {
|
|
367
|
+
respondWithError(res, error)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { BotApi } from '../api.js'
|
|
2
|
+
import type { AppConfig, WebUiConfig } from '../config/schema.js'
|
|
3
|
+
import type { Kernel } from '../kernel.js'
|
|
4
|
+
import type { LogBuffer } from '../logging/log-buffer.js'
|
|
5
|
+
import type { Logger } from '../logging/logger.js'
|
|
6
|
+
import type { AnyPluginDefinition, PluginInstaller } from '../plugin/types.js'
|
|
7
|
+
import type { Database } from '../storage/database.js'
|
|
8
|
+
import type { EventFeed } from './event-feed.js'
|
|
9
|
+
|
|
10
|
+
export interface WebUiRuntime {
|
|
11
|
+
generation: number
|
|
12
|
+
config: AppConfig
|
|
13
|
+
kernel: Kernel
|
|
14
|
+
api: BotApi
|
|
15
|
+
database: Database
|
|
16
|
+
installer: PluginInstaller
|
|
17
|
+
plugins: AnyPluginDefinition[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WebUiPaths {
|
|
21
|
+
configPath: string
|
|
22
|
+
dataDir: string
|
|
23
|
+
pluginDir: string
|
|
24
|
+
staticDir: string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WebUiAddress {
|
|
28
|
+
host: string
|
|
29
|
+
port: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ConfigDocument {
|
|
33
|
+
path: string
|
|
34
|
+
raw: string
|
|
35
|
+
hash: string
|
|
36
|
+
config: AppConfig
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ConfigValidationIssue {
|
|
40
|
+
path: string
|
|
41
|
+
message: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ConfigValidationResult {
|
|
45
|
+
ok: boolean
|
|
46
|
+
issues: ConfigValidationIssue[]
|
|
47
|
+
config?: AppConfig
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ConfigApplyResult {
|
|
51
|
+
ok: boolean
|
|
52
|
+
hash?: string
|
|
53
|
+
issues?: ConfigValidationIssue[]
|
|
54
|
+
error?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WebUiDeps {
|
|
58
|
+
logger: Logger
|
|
59
|
+
paths: WebUiPaths
|
|
60
|
+
version: string
|
|
61
|
+
logs: LogBuffer
|
|
62
|
+
events: EventFeed
|
|
63
|
+
|
|
64
|
+
/** Current webui settings; picked up after runtime restarts. */
|
|
65
|
+
getWebUiConfig(): WebUiConfig
|
|
66
|
+
|
|
67
|
+
readConfig(): Promise<ConfigDocument>
|
|
68
|
+
validateConfig(value: unknown): Promise<ConfigValidationResult>
|
|
69
|
+
applyConfig(
|
|
70
|
+
value: unknown,
|
|
71
|
+
baseHash?: string | undefined
|
|
72
|
+
): Promise<ConfigApplyResult>
|
|
73
|
+
|
|
74
|
+
restart(): Promise<boolean>
|
|
75
|
+
requestRestart(): void
|
|
76
|
+
pause(): void
|
|
77
|
+
resume(): void
|
|
78
|
+
|
|
79
|
+
getListening(): WebUiAddress | null
|
|
80
|
+
invalidateSessions(): void
|
|
81
|
+
runExclusive<T>(task: () => Promise<T>): Promise<T>
|
|
82
|
+
|
|
83
|
+
getRuntime(): WebUiRuntime | null
|
|
84
|
+
isRestarting(): boolean
|
|
85
|
+
getStartupError(): string | null
|
|
86
|
+
getRequiresProcessRestart(): boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface WebUiServerOptions {
|
|
90
|
+
config: WebUiConfig
|
|
91
|
+
deps: WebUiDeps
|
|
92
|
+
/** Maximum number of concurrent SSE clients. Defaults to 20. */
|
|
93
|
+
maxSseClients?: number | undefined
|
|
94
|
+
/** Maximum JSON request body size. Defaults to 1 MiB. */
|
|
95
|
+
bodyLimitBytes?: number | undefined
|
|
96
|
+
/** Login failures allowed per IP inside the window. Defaults to 5. */
|
|
97
|
+
loginMaxFailures?: number | undefined
|
|
98
|
+
/** Login failure window. Defaults to 15 minutes. */
|
|
99
|
+
loginWindowMs?: number | undefined
|
|
100
|
+
}
|