@jsbot-tellus/core 2.5.0 → 2.6.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.
Files changed (175) hide show
  1. package/README.md +8 -5
  2. package/dist/app/cli.d.ts +10 -0
  3. package/dist/app/cli.d.ts.map +1 -0
  4. package/dist/app/cli.js +37 -0
  5. package/dist/app/cli.js.map +1 -0
  6. package/dist/app/runtime.d.ts +121 -0
  7. package/dist/app/runtime.d.ts.map +1 -0
  8. package/dist/app/runtime.js +296 -0
  9. package/dist/app/runtime.js.map +1 -0
  10. package/dist/app.js +63 -273
  11. package/dist/app.js.map +1 -1
  12. package/dist/core/plugin/command-params.d.ts +3 -37
  13. package/dist/core/plugin/command-params.d.ts.map +1 -1
  14. package/dist/core/plugin/command-params.js +2 -580
  15. package/dist/core/plugin/command-params.js.map +1 -1
  16. package/dist/core/plugin/install/paths.d.ts +7 -0
  17. package/dist/core/plugin/install/paths.d.ts.map +1 -0
  18. package/dist/core/plugin/install/paths.js +49 -0
  19. package/dist/core/plugin/install/paths.js.map +1 -0
  20. package/dist/core/plugin/install/state.d.ts +23 -0
  21. package/dist/core/plugin/install/state.d.ts.map +1 -0
  22. package/dist/core/plugin/install/state.js +129 -0
  23. package/dist/core/plugin/install/state.js.map +1 -0
  24. package/dist/core/plugin/install/validate-script.d.ts +3 -0
  25. package/dist/core/plugin/install/validate-script.d.ts.map +1 -0
  26. package/dist/core/plugin/install/validate-script.js +154 -0
  27. package/dist/core/plugin/install/validate-script.js.map +1 -0
  28. package/dist/core/plugin/install-dir.d.ts +3 -28
  29. package/dist/core/plugin/install-dir.d.ts.map +1 -1
  30. package/dist/core/plugin/install-dir.js +3 -326
  31. package/dist/core/plugin/install-dir.js.map +1 -1
  32. package/dist/core/plugin/installer/checks.d.ts +6 -0
  33. package/dist/core/plugin/installer/checks.d.ts.map +1 -0
  34. package/dist/core/plugin/installer/checks.js +99 -0
  35. package/dist/core/plugin/installer/checks.js.map +1 -0
  36. package/dist/core/plugin/installer/support.d.ts +37 -0
  37. package/dist/core/plugin/installer/support.d.ts.map +1 -0
  38. package/dist/core/plugin/installer/support.js +67 -0
  39. package/dist/core/plugin/installer/support.js.map +1 -0
  40. package/dist/core/plugin/installer/warnings.d.ts +5 -0
  41. package/dist/core/plugin/installer/warnings.d.ts.map +1 -0
  42. package/dist/core/plugin/installer/warnings.js +28 -0
  43. package/dist/core/plugin/installer/warnings.js.map +1 -0
  44. package/dist/core/plugin/installer.d.ts +4 -30
  45. package/dist/core/plugin/installer.d.ts.map +1 -1
  46. package/dist/core/plugin/installer.js +21 -202
  47. package/dist/core/plugin/installer.js.map +1 -1
  48. package/dist/core/plugin/params/compile.d.ts +3 -0
  49. package/dist/core/plugin/params/compile.d.ts.map +1 -0
  50. package/dist/core/plugin/params/compile.js +23 -0
  51. package/dist/core/plugin/params/compile.js.map +1 -0
  52. package/dist/core/plugin/params/declaration.d.ts +9 -0
  53. package/dist/core/plugin/params/declaration.d.ts.map +1 -0
  54. package/dist/core/plugin/params/declaration.js +286 -0
  55. package/dist/core/plugin/params/declaration.js.map +1 -0
  56. package/dist/core/plugin/params/input.d.ts +5 -0
  57. package/dist/core/plugin/params/input.d.ts.map +1 -0
  58. package/dist/core/plugin/params/input.js +196 -0
  59. package/dist/core/plugin/params/input.js.map +1 -0
  60. package/dist/core/plugin/params/reply.d.ts +3 -0
  61. package/dist/core/plugin/params/reply.d.ts.map +1 -0
  62. package/dist/core/plugin/params/reply.js +49 -0
  63. package/dist/core/plugin/params/reply.js.map +1 -0
  64. package/dist/core/plugin/params/type-level.d.ts +24 -0
  65. package/dist/core/plugin/params/type-level.d.ts.map +1 -0
  66. package/dist/core/plugin/params/type-level.js +2 -0
  67. package/dist/core/plugin/params/type-level.js.map +1 -0
  68. package/dist/core/plugin/params/types.d.ts +33 -0
  69. package/dist/core/plugin/params/types.d.ts.map +1 -0
  70. package/dist/core/plugin/params/types.js +12 -0
  71. package/dist/core/plugin/params/types.js.map +1 -0
  72. package/dist/core/plugin/params/values.d.ts +7 -0
  73. package/dist/core/plugin/params/values.d.ts.map +1 -0
  74. package/dist/core/plugin/params/values.js +111 -0
  75. package/dist/core/plugin/params/values.js.map +1 -0
  76. package/dist/core/web/api.d.ts +2 -14
  77. package/dist/core/web/api.d.ts.map +1 -1
  78. package/dist/core/web/api.js +8 -500
  79. package/dist/core/web/api.js.map +1 -1
  80. package/dist/core/web/routes/actions.d.ts +4 -0
  81. package/dist/core/web/routes/actions.d.ts.map +1 -0
  82. package/dist/core/web/routes/actions.js +33 -0
  83. package/dist/core/web/routes/actions.js.map +1 -0
  84. package/dist/core/web/routes/config.d.ts +5 -0
  85. package/dist/core/web/routes/config.d.ts.map +1 -0
  86. package/dist/core/web/routes/config.js +30 -0
  87. package/dist/core/web/routes/config.js.map +1 -0
  88. package/dist/core/web/routes/kv.d.ts +6 -0
  89. package/dist/core/web/routes/kv.d.ts.map +1 -0
  90. package/dist/core/web/routes/kv.js +80 -0
  91. package/dist/core/web/routes/kv.js.map +1 -0
  92. package/dist/core/web/routes/plugins.d.ts +5 -0
  93. package/dist/core/web/routes/plugins.d.ts.map +1 -0
  94. package/dist/core/web/routes/plugins.js +98 -0
  95. package/dist/core/web/routes/plugins.js.map +1 -0
  96. package/dist/core/web/routes/sessions.d.ts +8 -0
  97. package/dist/core/web/routes/sessions.d.ts.map +1 -0
  98. package/dist/core/web/routes/sessions.js +130 -0
  99. package/dist/core/web/routes/sessions.js.map +1 -0
  100. package/dist/core/web/routes/shared.d.ts +18 -0
  101. package/dist/core/web/routes/shared.d.ts.map +1 -0
  102. package/dist/core/web/routes/shared.js +19 -0
  103. package/dist/core/web/routes/shared.js.map +1 -0
  104. package/dist/core/web/routes/status.d.ts +6 -0
  105. package/dist/core/web/routes/status.d.ts.map +1 -0
  106. package/dist/core/web/routes/status.js +132 -0
  107. package/dist/core/web/routes/status.js.map +1 -0
  108. package/dist/core/web/server/auth.d.ts +17 -0
  109. package/dist/core/web/server/auth.d.ts.map +1 -0
  110. package/dist/core/web/server/auth.js +68 -0
  111. package/dist/core/web/server/auth.js.map +1 -0
  112. package/dist/core/web/server/csp.d.ts +11 -0
  113. package/dist/core/web/server/csp.d.ts.map +1 -0
  114. package/dist/core/web/server/csp.js +31 -0
  115. package/dist/core/web/server/csp.js.map +1 -0
  116. package/dist/core/web/server/request.d.ts +6 -0
  117. package/dist/core/web/server/request.d.ts.map +1 -0
  118. package/dist/core/web/server/request.js +33 -0
  119. package/dist/core/web/server/request.js.map +1 -0
  120. package/dist/core/web/server/security.d.ts +4 -0
  121. package/dist/core/web/server/security.d.ts.map +1 -0
  122. package/dist/core/web/server/security.js +12 -0
  123. package/dist/core/web/server/security.js.map +1 -0
  124. package/dist/core/web/server/sse.d.ts +18 -0
  125. package/dist/core/web/server/sse.d.ts.map +1 -0
  126. package/dist/core/web/server/sse.js +49 -0
  127. package/dist/core/web/server/sse.js.map +1 -0
  128. package/dist/core/web/server/static.d.ts +16 -0
  129. package/dist/core/web/server/static.d.ts.map +1 -0
  130. package/dist/core/web/server/static.js +80 -0
  131. package/dist/core/web/server/static.js.map +1 -0
  132. package/dist/core/web/server.d.ts +0 -7
  133. package/dist/core/web/server.d.ts.map +1 -1
  134. package/dist/core/web/server.js +41 -263
  135. package/dist/core/web/server.js.map +1 -1
  136. package/docs/architecture.md +28 -11
  137. package/docs/configuration.md +1 -1
  138. package/docs/migration.md +2 -2
  139. package/docs/plugin-api.md +24 -11
  140. package/docs/plugin-development.md +1 -1
  141. package/package.json +19 -17
  142. package/src/app/cli.ts +47 -0
  143. package/src/app/runtime.ts +522 -0
  144. package/src/app.ts +87 -395
  145. package/src/core/plugin/command-params.ts +8 -871
  146. package/src/core/plugin/install/paths.ts +72 -0
  147. package/src/core/plugin/install/state.ts +196 -0
  148. package/src/core/plugin/install/validate-script.ts +154 -0
  149. package/src/core/plugin/install-dir.ts +19 -427
  150. package/src/core/plugin/installer/checks.ts +137 -0
  151. package/src/core/plugin/installer/support.ts +120 -0
  152. package/src/core/plugin/installer/warnings.ts +52 -0
  153. package/src/core/plugin/installer.ts +52 -301
  154. package/src/core/plugin/params/compile.ts +41 -0
  155. package/src/core/plugin/params/declaration.ts +394 -0
  156. package/src/core/plugin/params/input.ts +281 -0
  157. package/src/core/plugin/params/reply.ts +59 -0
  158. package/src/core/plugin/params/type-level.ts +75 -0
  159. package/src/core/plugin/params/types.ts +55 -0
  160. package/src/core/plugin/params/values.ts +150 -0
  161. package/src/core/web/api.ts +33 -759
  162. package/src/core/web/routes/actions.ts +43 -0
  163. package/src/core/web/routes/config.ts +42 -0
  164. package/src/core/web/routes/kv.ts +122 -0
  165. package/src/core/web/routes/plugins.ts +151 -0
  166. package/src/core/web/routes/sessions.ts +188 -0
  167. package/src/core/web/routes/shared.ts +42 -0
  168. package/src/core/web/routes/status.ts +191 -0
  169. package/src/core/web/server/auth.ts +126 -0
  170. package/src/core/web/server/csp.ts +33 -0
  171. package/src/core/web/server/request.ts +38 -0
  172. package/src/core/web/server/security.ts +14 -0
  173. package/src/core/web/server/sse.ts +74 -0
  174. package/src/core/web/server/static.ts +112 -0
  175. 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
+ }