@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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { SqliteSessionStore } from '../../storage/session.js'
|
|
2
|
+
import { logLevels, type LogLevel } from '../../logging/logger.js'
|
|
3
|
+
import type { FeedEventType } from '../event-feed.js'
|
|
4
|
+
import type { WebUiDeps, WebUiRuntime } from '../types.js'
|
|
5
|
+
import { HttpError, sendData, withTimeout } from '../http.js'
|
|
6
|
+
import { clampInt, requireRuntime, type ApiContext } from './shared.js'
|
|
7
|
+
|
|
8
|
+
interface LoginValue {
|
|
9
|
+
userId: number
|
|
10
|
+
nickname: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const loginCache = new WeakMap<
|
|
14
|
+
WebUiRuntime,
|
|
15
|
+
{ at: number; value: LoginValue | null }
|
|
16
|
+
>()
|
|
17
|
+
|
|
18
|
+
async function loginInfo(runtime: WebUiRuntime): Promise<LoginValue | null> {
|
|
19
|
+
if (!runtime.kernel.isConnected) return null
|
|
20
|
+
|
|
21
|
+
const cached = loginCache.get(runtime)
|
|
22
|
+
|
|
23
|
+
if (cached && Date.now() - cached.at < 10_000) return cached.value
|
|
24
|
+
|
|
25
|
+
let value: LoginValue | null = null
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const info = await withTimeout(runtime.api.getLoginInfo(), 2_000)
|
|
29
|
+
|
|
30
|
+
if (typeof info.user_id === 'number') {
|
|
31
|
+
value = {
|
|
32
|
+
userId: info.user_id,
|
|
33
|
+
nickname: info.nickname || String(info.user_id),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
value = null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
loginCache.set(runtime, { at: Date.now(), value })
|
|
41
|
+
|
|
42
|
+
return value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface RuntimeStatus {
|
|
46
|
+
generation: number
|
|
47
|
+
state: string
|
|
48
|
+
uptimeMs: number
|
|
49
|
+
connected: boolean
|
|
50
|
+
login: LoginValue | null
|
|
51
|
+
counts: {
|
|
52
|
+
plugins: number
|
|
53
|
+
loadedPlugins: number
|
|
54
|
+
enabledPlugins: number
|
|
55
|
+
sessions: number
|
|
56
|
+
kvPlugins: number
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function countKvPlugins(database: WebUiRuntime['database']): number {
|
|
61
|
+
const row = database.connection
|
|
62
|
+
.prepare('SELECT COUNT(DISTINCT plugin_id) AS count FROM plugin_kv')
|
|
63
|
+
.get() as { count: number } | undefined
|
|
64
|
+
|
|
65
|
+
return row?.count ?? 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function runtimeStatus(runtime: WebUiRuntime): Promise<RuntimeStatus> {
|
|
69
|
+
const statuses = runtime.kernel.listPlugins()
|
|
70
|
+
const sessions = await new SqliteSessionStore(runtime.database).count()
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
generation: runtime.generation,
|
|
74
|
+
state: runtime.kernel.state,
|
|
75
|
+
uptimeMs: runtime.kernel.uptimeMs,
|
|
76
|
+
connected: runtime.kernel.isConnected,
|
|
77
|
+
login: await loginInfo(runtime),
|
|
78
|
+
counts: {
|
|
79
|
+
plugins: statuses.length,
|
|
80
|
+
loadedPlugins: statuses.filter((entry) => entry.loaded).length,
|
|
81
|
+
enabledPlugins: statuses.filter((entry) => entry.enabled).length,
|
|
82
|
+
sessions,
|
|
83
|
+
kvPlugins: countKvPlugins(runtime.database),
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pathsInfo(deps: WebUiDeps) {
|
|
89
|
+
return {
|
|
90
|
+
config: deps.paths.configPath,
|
|
91
|
+
data: deps.paths.dataDir,
|
|
92
|
+
plugins: deps.paths.pluginDir,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function assertLevel(value: string): LogLevel {
|
|
97
|
+
if (!(logLevels as readonly string[]).includes(value)) {
|
|
98
|
+
throw new HttpError(400, `invalid log level: ${value}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return value as LogLevel
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const FEED_TYPES: readonly FeedEventType[] = [
|
|
105
|
+
'message',
|
|
106
|
+
'notice',
|
|
107
|
+
'request',
|
|
108
|
+
'meta',
|
|
109
|
+
'other',
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
function assertFeedType(value: string): FeedEventType {
|
|
113
|
+
if (!FEED_TYPES.includes(value as FeedEventType)) {
|
|
114
|
+
throw new HttpError(400, `invalid event type: ${value}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return value as FeedEventType
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function handleStatus(ctx: ApiContext): Promise<void> {
|
|
121
|
+
const { deps, res } = ctx
|
|
122
|
+
const runtime = deps.getRuntime()
|
|
123
|
+
const webui = {
|
|
124
|
+
listening: deps.getListening(),
|
|
125
|
+
requiresProcessRestart: deps.getRequiresProcessRestart(),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!runtime) {
|
|
129
|
+
sendData(res, 200, {
|
|
130
|
+
restarting: deps.isRestarting(),
|
|
131
|
+
startupError: deps.getStartupError(),
|
|
132
|
+
runtime: null,
|
|
133
|
+
webui,
|
|
134
|
+
version: deps.version,
|
|
135
|
+
paths: pathsInfo(deps),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
sendData(res, 200, {
|
|
142
|
+
restarting: deps.isRestarting(),
|
|
143
|
+
startupError: deps.getStartupError(),
|
|
144
|
+
runtime: await runtimeStatus(runtime),
|
|
145
|
+
webui,
|
|
146
|
+
version: deps.version,
|
|
147
|
+
paths: pathsInfo(deps),
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function handleCommands(ctx: ApiContext): Promise<void> {
|
|
152
|
+
sendData(ctx.res, 200, requireRuntime(ctx.deps).kernel.listCommands())
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function handleLogsList(ctx: ApiContext): Promise<void> {
|
|
156
|
+
const level = ctx.search.get('level')
|
|
157
|
+
|
|
158
|
+
if (level) assertLevel(level)
|
|
159
|
+
|
|
160
|
+
const scope = ctx.search.get('scope') ?? undefined
|
|
161
|
+
const query = ctx.search.get('query') ?? undefined
|
|
162
|
+
const limit = clampInt(ctx.search.get('limit'), 200, 1, 1_000)
|
|
163
|
+
|
|
164
|
+
sendData(
|
|
165
|
+
ctx.res,
|
|
166
|
+
200,
|
|
167
|
+
ctx.deps.logs.list({
|
|
168
|
+
...(level ? { level: assertLevel(level) } : {}),
|
|
169
|
+
scope,
|
|
170
|
+
query,
|
|
171
|
+
limit,
|
|
172
|
+
})
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function handleEventsList(ctx: ApiContext): Promise<void> {
|
|
177
|
+
const type = ctx.search.get('type')
|
|
178
|
+
|
|
179
|
+
if (type) assertFeedType(type)
|
|
180
|
+
|
|
181
|
+
const limit = clampInt(ctx.search.get('limit'), 100, 1, 500)
|
|
182
|
+
|
|
183
|
+
sendData(
|
|
184
|
+
ctx.res,
|
|
185
|
+
200,
|
|
186
|
+
ctx.deps.events.list({
|
|
187
|
+
...(type ? { type: assertFeedType(type) } : {}),
|
|
188
|
+
limit,
|
|
189
|
+
})
|
|
190
|
+
)
|
|
191
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import { AuthManager, type AuthSession } from '../auth.js'
|
|
3
|
+
import {
|
|
4
|
+
clearCookie,
|
|
5
|
+
clientIp,
|
|
6
|
+
parseCookies,
|
|
7
|
+
readJsonBody,
|
|
8
|
+
sendData,
|
|
9
|
+
sendError,
|
|
10
|
+
serializeCookie,
|
|
11
|
+
} from '../http.js'
|
|
12
|
+
import { HttpError } from '../http.js'
|
|
13
|
+
import type { WebUiDeps } from '../types.js'
|
|
14
|
+
|
|
15
|
+
export const SESSION_COOKIE = 'jsbot_webui'
|
|
16
|
+
|
|
17
|
+
export function authenticate(
|
|
18
|
+
auth: AuthManager,
|
|
19
|
+
req: IncomingMessage
|
|
20
|
+
): AuthSession | null {
|
|
21
|
+
const cookies = parseCookies(req.headers.cookie)
|
|
22
|
+
|
|
23
|
+
return auth.authenticate(cookies[SESSION_COOKIE])
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function tokenOf(req: IncomingMessage): string | undefined {
|
|
27
|
+
const cookies = parseCookies(req.headers.cookie)
|
|
28
|
+
|
|
29
|
+
return cookies[SESSION_COOKIE]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isSecure(deps: WebUiDeps, req: IncomingMessage): boolean {
|
|
33
|
+
if (!deps.getWebUiConfig().trustProxy) return false
|
|
34
|
+
|
|
35
|
+
const proto = req.headers['x-forwarded-proto']
|
|
36
|
+
|
|
37
|
+
return typeof proto === 'string' && proto.split(',')[0]?.trim() === 'https'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HandleAuthOptions {
|
|
41
|
+
req: IncomingMessage
|
|
42
|
+
res: ServerResponse
|
|
43
|
+
action: string | undefined
|
|
44
|
+
auth: AuthManager
|
|
45
|
+
deps: WebUiDeps
|
|
46
|
+
bodyLimit: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function handleAuth(options: HandleAuthOptions): Promise<void> {
|
|
50
|
+
const { req, res, action, auth, deps, bodyLimit } = options
|
|
51
|
+
const method = req.method ?? 'GET'
|
|
52
|
+
|
|
53
|
+
if (action === 'login' && method === 'POST') {
|
|
54
|
+
const body = await readJsonBody(req, bodyLimit)
|
|
55
|
+
const value =
|
|
56
|
+
typeof body === 'object' && body !== null
|
|
57
|
+
? (body as Record<string, unknown>)
|
|
58
|
+
: {}
|
|
59
|
+
const ip = clientIp(req, deps.getWebUiConfig().trustProxy)
|
|
60
|
+
const result = await auth.login(
|
|
61
|
+
value['username'],
|
|
62
|
+
value['password'],
|
|
63
|
+
ip
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if (!result.ok || !result.session) {
|
|
67
|
+
const headers: Record<string, string | undefined> = {}
|
|
68
|
+
|
|
69
|
+
if (result.retryAfterSeconds !== undefined) {
|
|
70
|
+
headers['retry-after'] = String(result.retryAfterSeconds)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
sendError(
|
|
74
|
+
res,
|
|
75
|
+
result.status,
|
|
76
|
+
result.message ?? 'login failed',
|
|
77
|
+
undefined,
|
|
78
|
+
headers
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
res.setHeader(
|
|
85
|
+
'set-cookie',
|
|
86
|
+
serializeCookie(SESSION_COOKIE, result.session.token, {
|
|
87
|
+
maxAgeSeconds: deps.getWebUiConfig().sessionTtlHours * 3600,
|
|
88
|
+
secure: isSecure(deps, req),
|
|
89
|
+
})
|
|
90
|
+
)
|
|
91
|
+
sendData(res, result.status, {
|
|
92
|
+
username: result.session.username,
|
|
93
|
+
expiresAt: result.session.expiresAt,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (action === 'logout' && method === 'POST') {
|
|
100
|
+
const session = authenticate(auth, req)
|
|
101
|
+
|
|
102
|
+
auth.logout(tokenOf(req))
|
|
103
|
+
deps.logger.info(`webui logout: ${session?.username ?? 'unknown'}`)
|
|
104
|
+
res.setHeader('set-cookie', clearCookie(SESSION_COOKIE))
|
|
105
|
+
sendData(res, 200, { ok: true })
|
|
106
|
+
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (action === 'me' && method === 'GET') {
|
|
111
|
+
const session = authenticate(auth, req)
|
|
112
|
+
|
|
113
|
+
if (session === null) {
|
|
114
|
+
throw new HttpError(401, '未登录或会话已过期')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
sendData(res, 200, {
|
|
118
|
+
username: session.username,
|
|
119
|
+
expiresAt: session.expiresAt,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
throw new HttpError(404, `not found: ${method} /api/auth/${action ?? ''}`)
|
|
126
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CSP for the WebUI. webui/index.html ships a single authorized inline
|
|
7
|
+
* script (the no-flash appearance bootstrap for the first paint); its exact
|
|
8
|
+
* hash is derived from the built index.html on disk so editing the bootstrap
|
|
9
|
+
* only needs a rebuild, never a source edit. Every other inline script
|
|
10
|
+
* stays blocked.
|
|
11
|
+
*/
|
|
12
|
+
export function buildCsp(scriptSrc: string): string {
|
|
13
|
+
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'`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Extracts the bootstrap script's sha256 from a built index.html. */
|
|
17
|
+
export function cspForStaticDir(staticDir: string): string | null {
|
|
18
|
+
try {
|
|
19
|
+
const html = readFileSync(join(staticDir, 'index.html'), 'utf8')
|
|
20
|
+
const match = /<script>([\s\S]*?)<\/script>/.exec(html)
|
|
21
|
+
const bootstrap = match?.[1]
|
|
22
|
+
|
|
23
|
+
if (bootstrap === undefined) return null
|
|
24
|
+
|
|
25
|
+
const hash = createHash('sha256')
|
|
26
|
+
.update(bootstrap, 'utf8')
|
|
27
|
+
.digest('base64')
|
|
28
|
+
|
|
29
|
+
return buildCsp(`'self' 'sha256-${hash}'`)
|
|
30
|
+
} catch {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http'
|
|
2
|
+
import type { URLSearchParamsLike } from '../http.js'
|
|
3
|
+
|
|
4
|
+
export function parseSearch(req: IncomingMessage): URLSearchParamsLike {
|
|
5
|
+
const index = (req.url ?? '').indexOf('?')
|
|
6
|
+
const query = index >= 0 ? (req.url ?? '').slice(index + 1) : ''
|
|
7
|
+
|
|
8
|
+
return new URLSearchParams(query)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function segmentsOf(pathname: string): string[] {
|
|
12
|
+
return pathname
|
|
13
|
+
.split('/')
|
|
14
|
+
.filter((segment) => segment.length > 0)
|
|
15
|
+
.map((segment) => {
|
|
16
|
+
try {
|
|
17
|
+
return decodeURIComponent(segment)
|
|
18
|
+
} catch {
|
|
19
|
+
return segment
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function pathnameOf(req: IncomingMessage): string {
|
|
25
|
+
const url = req.url ?? '/'
|
|
26
|
+
const index = url.indexOf('?')
|
|
27
|
+
const raw = index >= 0 ? url.slice(0, index) : url
|
|
28
|
+
|
|
29
|
+
if (raw.includes('%')) {
|
|
30
|
+
try {
|
|
31
|
+
return decodeURIComponent(raw)
|
|
32
|
+
} catch {
|
|
33
|
+
return raw
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return raw
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http'
|
|
2
|
+
|
|
3
|
+
export const SECURITY_HEADERS: Record<string, string> = {
|
|
4
|
+
'x-content-type-options': 'nosniff',
|
|
5
|
+
'x-frame-options': 'DENY',
|
|
6
|
+
'referrer-policy': 'no-referrer',
|
|
7
|
+
'cross-origin-opener-policy': 'same-origin',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function applySecurityHeaders(res: ServerResponse): void {
|
|
11
|
+
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
|
|
12
|
+
res.setHeader(key, value)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import { HttpError, safeStringify } from '../http.js'
|
|
3
|
+
|
|
4
|
+
export const SSE_HEARTBEAT_MS = 15_000
|
|
5
|
+
export const DEFAULT_MAX_SSE_CLIENTS = 20
|
|
6
|
+
export const SSE_TIMEOUT_MS = 60_000
|
|
7
|
+
|
|
8
|
+
export interface SseConnection {
|
|
9
|
+
res: ServerResponse
|
|
10
|
+
close(): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface OpenSseOptions {
|
|
14
|
+
req: IncomingMessage
|
|
15
|
+
res: ServerResponse
|
|
16
|
+
subscribe: (listener: (entry: unknown) => void) => () => void
|
|
17
|
+
openedAt: number
|
|
18
|
+
maxClients: number
|
|
19
|
+
connections: Set<SseConnection>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function openSse(options: OpenSseOptions): void {
|
|
23
|
+
const { req, res, subscribe, openedAt, maxClients, connections } = options
|
|
24
|
+
|
|
25
|
+
if (connections.size >= maxClients) {
|
|
26
|
+
throw new HttpError(503, '实时连接数量已达上限')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
res.writeHead(200, {
|
|
30
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
31
|
+
'cache-control': 'no-store, no-transform',
|
|
32
|
+
connection: 'keep-alive',
|
|
33
|
+
})
|
|
34
|
+
res.write(':ok\n\n')
|
|
35
|
+
|
|
36
|
+
const connection: SseConnection = {
|
|
37
|
+
res,
|
|
38
|
+
close: () => {
|
|
39
|
+
unsubscribe()
|
|
40
|
+
clearInterval(heartbeat)
|
|
41
|
+
connections.delete(connection)
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
res.end()
|
|
45
|
+
} catch {
|
|
46
|
+
// Already closed.
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let closing = false
|
|
52
|
+
|
|
53
|
+
const unsubscribe = subscribe((entry) => {
|
|
54
|
+
if (closing) return
|
|
55
|
+
|
|
56
|
+
res.write(`data: ${safeStringify(entry)}\n\n`)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const heartbeat = setInterval(() => {
|
|
60
|
+
res.write(`:keepalive ${Date.now() - openedAt}\n\n`)
|
|
61
|
+
}, SSE_HEARTBEAT_MS)
|
|
62
|
+
|
|
63
|
+
req.on('close', () => {
|
|
64
|
+
if (closing) return
|
|
65
|
+
|
|
66
|
+
closing = true
|
|
67
|
+
|
|
68
|
+
unsubscribe()
|
|
69
|
+
clearInterval(heartbeat)
|
|
70
|
+
connections.delete(connection)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
connections.add(connection)
|
|
74
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs'
|
|
2
|
+
import { stat } from 'node:fs/promises'
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
4
|
+
import { extname, join, resolve, sep } from 'node:path'
|
|
5
|
+
import { HttpError, isImmutableAsset, mimeType, withTimeout } from '../http.js'
|
|
6
|
+
|
|
7
|
+
export interface FallbackPageOptions {
|
|
8
|
+
listening: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const FALLBACK_BODY = (
|
|
12
|
+
options: FallbackPageOptions
|
|
13
|
+
): string => `<!doctype html>
|
|
14
|
+
<html lang="zh-CN"><head><meta charset="utf-8">
|
|
15
|
+
<title>JsBot WebUI</title>
|
|
16
|
+
<style>body{font-family:system-ui,sans-serif;max-width:36em;margin:4rem auto;padding:0 1rem;color:#333}</style>
|
|
17
|
+
</head><body>
|
|
18
|
+
<h1>WebUI 前端未构建</h1>
|
|
19
|
+
<p>管理 API 正常运行,但静态资源缺失。请在项目根目录执行:</p>
|
|
20
|
+
<pre><code>npm run build:webui</code></pre>
|
|
21
|
+
<p>然后重启进程,访问 <code>${options.listening}</code>。</p>
|
|
22
|
+
<p>API 仍可通过 <code>/api/health</code>、登录和各接口访问。</p>
|
|
23
|
+
</body></html>
|
|
24
|
+
`
|
|
25
|
+
|
|
26
|
+
export interface ServeStaticOptions {
|
|
27
|
+
req: IncomingMessage
|
|
28
|
+
res: ServerResponse
|
|
29
|
+
pathname: string
|
|
30
|
+
staticDir: string | null
|
|
31
|
+
fallbackBody: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function serveStatic(options: ServeStaticOptions): Promise<void> {
|
|
35
|
+
const { req, res, pathname, staticDir, fallbackBody } = options
|
|
36
|
+
|
|
37
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
38
|
+
throw new HttpError(405, `method not allowed: ${req.method}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (staticDir === null) {
|
|
42
|
+
res.setHeader('content-type', 'text/html; charset=utf-8')
|
|
43
|
+
res.setHeader('cache-control', 'no-store')
|
|
44
|
+
res.writeHead(503)
|
|
45
|
+
res.end(fallbackBody)
|
|
46
|
+
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const cleaned = pathname.replace(/\/+$/, '') || '/'
|
|
51
|
+
let filePath =
|
|
52
|
+
cleaned === '/'
|
|
53
|
+
? join(staticDir, 'index.html')
|
|
54
|
+
: resolve(staticDir, `.${cleaned}`)
|
|
55
|
+
|
|
56
|
+
if (!filePath.startsWith(staticDir + sep) && filePath !== staticDir) {
|
|
57
|
+
throw new HttpError(403, 'forbidden')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let fileStat = await statOrNull(filePath)
|
|
61
|
+
|
|
62
|
+
if (fileStat?.isDirectory()) {
|
|
63
|
+
filePath = join(filePath, 'index.html')
|
|
64
|
+
fileStat = await statOrNull(filePath)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (fileStat === null) {
|
|
68
|
+
// SPA fallback: only routes without a file extension.
|
|
69
|
+
if (extname(cleaned) === '') {
|
|
70
|
+
filePath = join(staticDir, 'index.html')
|
|
71
|
+
fileStat = await statOrNull(filePath)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (fileStat === null) throw new HttpError(404, 'not found')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const cacheControl = isImmutableAsset(cleaned)
|
|
78
|
+
? 'public, max-age=31536000, immutable'
|
|
79
|
+
: 'no-cache'
|
|
80
|
+
|
|
81
|
+
res.setHeader('content-type', mimeType(filePath))
|
|
82
|
+
res.setHeader('cache-control', cacheControl)
|
|
83
|
+
res.setHeader('content-length', String(fileStat.size))
|
|
84
|
+
res.writeHead(200)
|
|
85
|
+
|
|
86
|
+
if (req.method === 'HEAD') {
|
|
87
|
+
res.end()
|
|
88
|
+
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const stream = createReadStream(filePath)
|
|
93
|
+
|
|
94
|
+
stream.on('error', () => {
|
|
95
|
+
try {
|
|
96
|
+
res.end()
|
|
97
|
+
} catch {
|
|
98
|
+
// Already closed.
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
stream.pipe(res)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function statOrNull(
|
|
105
|
+
filePath: string
|
|
106
|
+
): Promise<Awaited<ReturnType<typeof stat>> | null> {
|
|
107
|
+
try {
|
|
108
|
+
return await withTimeout(stat(filePath), 1_000)
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
}
|