@jsbot-tellus/core 2.2.0 → 2.4.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/router.d.ts +3 -0
- package/dist/core/dispatch/router.d.ts.map +1 -1
- package/dist/core/dispatch/router.js +13 -0
- 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 +2 -2
- package/dist/core/plugin/command-params.d.ts.map +1 -1
- package/dist/core/plugin/command-params.js +35 -0
- 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 +1 -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/router.ts +15 -0
- 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 +48 -2
- 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,245 @@
|
|
|
1
|
+
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import type { WebUiConfig } from '../config/schema.js'
|
|
4
|
+
|
|
5
|
+
const scryptAsync = promisify(scrypt)
|
|
6
|
+
|
|
7
|
+
export interface AuthSession {
|
|
8
|
+
token: string
|
|
9
|
+
username: string
|
|
10
|
+
expiresAt: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AuthLoginResult {
|
|
14
|
+
ok: boolean
|
|
15
|
+
status: number
|
|
16
|
+
message?: string
|
|
17
|
+
session?: AuthSession
|
|
18
|
+
retryAfterSeconds?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AuthManagerOptions {
|
|
22
|
+
getConfig(): WebUiConfig
|
|
23
|
+
now?: (() => number) | undefined
|
|
24
|
+
/** Failures allowed per IP inside the window. Defaults to 5. */
|
|
25
|
+
maxFailures?: number | undefined
|
|
26
|
+
/** Failure window. Defaults to 15 minutes. */
|
|
27
|
+
windowMs?: number | undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface FailureEntry {
|
|
31
|
+
count: number
|
|
32
|
+
resetAt: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface PasswordHash {
|
|
36
|
+
salt: Buffer
|
|
37
|
+
hash: Buffer
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function hashPassword(password: string, salt: Buffer): Promise<Buffer> {
|
|
41
|
+
return (await scryptAsync(password, salt, 64)) as Buffer
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class AuthManager {
|
|
45
|
+
private readonly sessions = new Map<string, AuthSession>()
|
|
46
|
+
private readonly failures = new Map<string, FailureEntry>()
|
|
47
|
+
private cachedHash: (PasswordHash & { password: string }) | null = null
|
|
48
|
+
|
|
49
|
+
constructor(private readonly options: AuthManagerOptions) {}
|
|
50
|
+
|
|
51
|
+
async login(
|
|
52
|
+
username: unknown,
|
|
53
|
+
password: unknown,
|
|
54
|
+
ip: string
|
|
55
|
+
): Promise<AuthLoginResult> {
|
|
56
|
+
const now = this.now()
|
|
57
|
+
const config = this.options.getConfig()
|
|
58
|
+
|
|
59
|
+
if (!config.enabled) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
status: 503,
|
|
63
|
+
message: 'webui 已禁用,无法登录',
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!this.hasCredentials(config)) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
status: 503,
|
|
71
|
+
message: 'webui 账号密码未配置',
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const blocked = this.blockedRetryAfter(ip, now)
|
|
76
|
+
|
|
77
|
+
if (blocked !== null) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
status: 429,
|
|
81
|
+
message: '登录尝试过于频繁,请稍后再试',
|
|
82
|
+
retryAfterSeconds: blocked,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.cleanup(now)
|
|
87
|
+
|
|
88
|
+
const providedUsername = typeof username === 'string' ? username : ''
|
|
89
|
+
const providedPassword = typeof password === 'string' ? password : ''
|
|
90
|
+
|
|
91
|
+
const usernameMatches = providedUsername === config.username
|
|
92
|
+
const passwordMatches = await this.comparePassword(
|
|
93
|
+
config.password ?? '',
|
|
94
|
+
providedPassword
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if (!usernameMatches || !passwordMatches) {
|
|
98
|
+
this.recordFailure(ip, now)
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
status: 401,
|
|
103
|
+
message: '用户名或密码错误',
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.failures.delete(ip)
|
|
108
|
+
|
|
109
|
+
const token = randomBytes(32).toString('base64url')
|
|
110
|
+
const expiresAt = now + (config.sessionTtlHours ?? 12) * 60 * 60 * 1000
|
|
111
|
+
const session: AuthSession = {
|
|
112
|
+
token,
|
|
113
|
+
username: config.username ?? '',
|
|
114
|
+
expiresAt,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.sessions.set(token, session)
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
status: 200,
|
|
122
|
+
session,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
authenticate(token: string | undefined): AuthSession | null {
|
|
127
|
+
if (!token) return null
|
|
128
|
+
|
|
129
|
+
const session = this.sessions.get(token)
|
|
130
|
+
|
|
131
|
+
if (!session) return null
|
|
132
|
+
|
|
133
|
+
if (session.expiresAt <= this.now()) {
|
|
134
|
+
this.sessions.delete(token)
|
|
135
|
+
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return session
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
logout(token: string | undefined): void {
|
|
143
|
+
if (token) this.sessions.delete(token)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
invalidateAll(): void {
|
|
147
|
+
this.sessions.clear()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
clearExpired(): void {
|
|
151
|
+
this.cleanup(this.now())
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private now(): number {
|
|
155
|
+
return (this.options.now ?? Date.now)()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private hasCredentials(config: WebUiConfig): boolean {
|
|
159
|
+
return (
|
|
160
|
+
typeof config.username === 'string' &&
|
|
161
|
+
config.username.length > 0 &&
|
|
162
|
+
typeof config.password === 'string' &&
|
|
163
|
+
config.password.length > 0
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private async comparePassword(
|
|
168
|
+
expected: string,
|
|
169
|
+
provided: string
|
|
170
|
+
): Promise<boolean> {
|
|
171
|
+
const { salt, hash } = await this.hashFor(expected)
|
|
172
|
+
const providedHash = await hashPassword(provided, salt)
|
|
173
|
+
|
|
174
|
+
return timingSafeEqual(hash, providedHash)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async hashFor(password: string): Promise<PasswordHash> {
|
|
178
|
+
const cached = this.cachedHash
|
|
179
|
+
|
|
180
|
+
if (cached && cached.password === password) return cached
|
|
181
|
+
|
|
182
|
+
const salt = randomBytes(16)
|
|
183
|
+
const hash = await hashPassword(password, salt)
|
|
184
|
+
|
|
185
|
+
this.cachedHash = { password, salt, hash }
|
|
186
|
+
|
|
187
|
+
return { salt, hash }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private maxFailures(): number {
|
|
191
|
+
return this.options.maxFailures ?? 5
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private windowMs(): number {
|
|
195
|
+
return this.options.windowMs ?? 15 * 60 * 1000
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private blockedRetryAfter(ip: string, now: number): number | null {
|
|
199
|
+
const entry = this.failures.get(ip)
|
|
200
|
+
|
|
201
|
+
if (!entry) return null
|
|
202
|
+
|
|
203
|
+
if (entry.resetAt <= now) {
|
|
204
|
+
this.failures.delete(ip)
|
|
205
|
+
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (entry.count < this.maxFailures()) return null
|
|
210
|
+
|
|
211
|
+
return Math.max(Math.ceil((entry.resetAt - now) / 1000), 1)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private recordFailure(ip: string, now: number): void {
|
|
215
|
+
const windowMs = this.windowMs()
|
|
216
|
+
const entry = this.failures.get(ip)
|
|
217
|
+
|
|
218
|
+
if (!entry || entry.resetAt <= now) {
|
|
219
|
+
this.failures.set(ip, { count: 1, resetAt: now + windowMs })
|
|
220
|
+
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this.failures.set(ip, {
|
|
225
|
+
count: entry.count + 1,
|
|
226
|
+
resetAt: entry.resetAt,
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
if (this.failures.size > 1_000) {
|
|
230
|
+
for (const [key, value] of this.failures) {
|
|
231
|
+
if (value.resetAt <= now) this.failures.delete(key)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private cleanup(now: number): void {
|
|
237
|
+
for (const [token, session] of this.sessions) {
|
|
238
|
+
if (session.expiresAt <= now) this.sessions.delete(token)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const [ip, entry] of this.failures) {
|
|
242
|
+
if (entry.resetAt <= now) this.failures.delete(ip)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { copyFile, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
|
3
|
+
import {
|
|
4
|
+
appConfigSchema,
|
|
5
|
+
formatConfigIssues,
|
|
6
|
+
type AppConfig,
|
|
7
|
+
type PluginEnvelope,
|
|
8
|
+
} from '../config/schema.js'
|
|
9
|
+
import { stripPluginEnvelope } from '../plugin/host.js'
|
|
10
|
+
import type { AnyPluginDefinition } from '../plugin/types.js'
|
|
11
|
+
import type {
|
|
12
|
+
ConfigApplyResult,
|
|
13
|
+
ConfigDocument,
|
|
14
|
+
ConfigValidationIssue,
|
|
15
|
+
ConfigValidationResult,
|
|
16
|
+
} from './types.js'
|
|
17
|
+
|
|
18
|
+
export class ConfigStoreError extends Error {
|
|
19
|
+
readonly status: number
|
|
20
|
+
readonly issues: ConfigValidationIssue[]
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
status: number,
|
|
24
|
+
message: string,
|
|
25
|
+
issues: ConfigValidationIssue[] = []
|
|
26
|
+
) {
|
|
27
|
+
super(message)
|
|
28
|
+
this.name = 'ConfigStoreError'
|
|
29
|
+
this.status = status
|
|
30
|
+
this.issues = issues
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ConfigStoreOptions {
|
|
35
|
+
configPath: string
|
|
36
|
+
getPlugins(): AnyPluginDefinition[]
|
|
37
|
+
restart(): Promise<boolean>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
45
|
+
return isRecord(value) ? value : {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function mergeSecrets(current: AppConfig, incoming: unknown): unknown {
|
|
49
|
+
if (!isRecord(incoming)) return incoming
|
|
50
|
+
|
|
51
|
+
const next = asRecord(JSON.parse(JSON.stringify(incoming)))
|
|
52
|
+
const accessToken = next['accessToken']
|
|
53
|
+
|
|
54
|
+
if (accessToken === null) {
|
|
55
|
+
delete next['accessToken']
|
|
56
|
+
} else if (accessToken === undefined || accessToken === '') {
|
|
57
|
+
if (current.accessToken !== undefined) {
|
|
58
|
+
next['accessToken'] = current.accessToken
|
|
59
|
+
} else {
|
|
60
|
+
delete next['accessToken']
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const webui = next['webui']
|
|
65
|
+
|
|
66
|
+
if (isRecord(webui)) {
|
|
67
|
+
const password = webui['password']
|
|
68
|
+
|
|
69
|
+
if (password === null) {
|
|
70
|
+
delete webui['password']
|
|
71
|
+
} else if (password === undefined || password === '') {
|
|
72
|
+
if (current.webui.password !== undefined) {
|
|
73
|
+
webui['password'] = current.webui.password
|
|
74
|
+
} else {
|
|
75
|
+
delete webui['password']
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return next
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface MaskedConfig {
|
|
84
|
+
config: Record<string, unknown>
|
|
85
|
+
accessTokenSet: boolean
|
|
86
|
+
webuiPasswordSet: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function maskConfig(config: AppConfig): MaskedConfig {
|
|
90
|
+
const masked = asRecord(JSON.parse(JSON.stringify(config)))
|
|
91
|
+
let accessTokenSet = false
|
|
92
|
+
let webuiPasswordSet = false
|
|
93
|
+
|
|
94
|
+
if (typeof masked['accessToken'] === 'string') {
|
|
95
|
+
masked['accessToken'] = ''
|
|
96
|
+
accessTokenSet = true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const webui = asRecord(masked['webui'])
|
|
100
|
+
|
|
101
|
+
if (typeof webui['password'] === 'string') {
|
|
102
|
+
webui['password'] = ''
|
|
103
|
+
webuiPasswordSet = true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { config: masked, accessTokenSet, webuiPasswordSet }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function issuesFrom(
|
|
110
|
+
zodIssues: Array<{ path: PropertyKey[]; message: string }>
|
|
111
|
+
): ConfigValidationIssue[] {
|
|
112
|
+
return zodIssues.map((issue) => {
|
|
113
|
+
const path = issue.path
|
|
114
|
+
.map((part) => String(part))
|
|
115
|
+
.filter((part) => part !== '')
|
|
116
|
+
.join('.')
|
|
117
|
+
|
|
118
|
+
return { path, message: issue.message }
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validatePlugins(
|
|
123
|
+
config: AppConfig,
|
|
124
|
+
definitions: AnyPluginDefinition[]
|
|
125
|
+
): ConfigValidationIssue[] {
|
|
126
|
+
const issues: ConfigValidationIssue[] = []
|
|
127
|
+
const byId = new Map(
|
|
128
|
+
definitions.map((definition) => [definition.manifest.id, definition])
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
for (const [id, envelope] of Object.entries(config.plugins)) {
|
|
132
|
+
const definition = byId.get(id)
|
|
133
|
+
|
|
134
|
+
if (!definition) {
|
|
135
|
+
issues.push({
|
|
136
|
+
path: `plugins.${id}`,
|
|
137
|
+
message: `unknown plugin id: ${id}`,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const enabled =
|
|
144
|
+
(envelope as PluginEnvelope).enabled ??
|
|
145
|
+
definition.manifest.defaultEnabled ??
|
|
146
|
+
false
|
|
147
|
+
|
|
148
|
+
if (!enabled) continue
|
|
149
|
+
|
|
150
|
+
const result = definition.manifest.configSchema.safeParse(
|
|
151
|
+
stripPluginEnvelope(envelope as PluginEnvelope)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if (!result.success) {
|
|
155
|
+
issues.push(
|
|
156
|
+
...issuesFrom(result.error.issues).map((issue) => ({
|
|
157
|
+
path: `plugins.${id}${issue.path.length > 0 ? `.${issue.path}` : ''}`,
|
|
158
|
+
message: issue.message,
|
|
159
|
+
}))
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return issues
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function atomicWrite(filePath: string, raw: string): Promise<void> {
|
|
168
|
+
let mode: number | undefined
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
mode = (await stat(filePath)).mode & 0o777
|
|
172
|
+
} catch {
|
|
173
|
+
mode = undefined
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const temporary = `${filePath}.tmp-${randomUUID()}`
|
|
177
|
+
|
|
178
|
+
await writeFile(temporary, raw, {
|
|
179
|
+
encoding: 'utf8',
|
|
180
|
+
...(mode !== undefined ? { mode } : {}),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
await copyFile(filePath, `${filePath}.bak`)
|
|
185
|
+
} catch {
|
|
186
|
+
// Missing source file; nothing to back up.
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await rename(temporary, filePath)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function restoreRaw(filePath: string, raw: string): Promise<void> {
|
|
193
|
+
const temporary = `${filePath}.tmp-${randomUUID()}`
|
|
194
|
+
|
|
195
|
+
await writeFile(temporary, raw, 'utf8')
|
|
196
|
+
await rename(temporary, filePath)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export class ConfigStore {
|
|
200
|
+
private tail: Promise<unknown> = Promise.resolve()
|
|
201
|
+
|
|
202
|
+
constructor(private readonly options: ConfigStoreOptions) {}
|
|
203
|
+
|
|
204
|
+
async read(): Promise<ConfigDocument> {
|
|
205
|
+
let raw: string
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
raw = await readFile(this.options.configPath, 'utf8')
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw new ConfigStoreError(
|
|
211
|
+
500,
|
|
212
|
+
`cannot read config file: ${this.options.configPath}`,
|
|
213
|
+
[]
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let json: unknown
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
json = JSON.parse(raw) as unknown
|
|
221
|
+
} catch {
|
|
222
|
+
throw new ConfigStoreError(
|
|
223
|
+
500,
|
|
224
|
+
`invalid JSON in config file: ${this.options.configPath}`
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const parsed = appConfigSchema.safeParse(json)
|
|
229
|
+
|
|
230
|
+
if (!parsed.success) {
|
|
231
|
+
throw new ConfigStoreError(
|
|
232
|
+
500,
|
|
233
|
+
'invalid config file',
|
|
234
|
+
issuesFrom(parsed.error.issues)
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
path: this.options.configPath,
|
|
240
|
+
raw,
|
|
241
|
+
hash: createHash('sha256').update(raw).digest('hex'),
|
|
242
|
+
config: parsed.data,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async validate(value: unknown): Promise<ConfigValidationResult> {
|
|
247
|
+
const document = await this.read()
|
|
248
|
+
const merged = mergeSecrets(document.config, value)
|
|
249
|
+
const parsed = appConfigSchema.safeParse(merged)
|
|
250
|
+
|
|
251
|
+
if (!parsed.success) {
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
issues: issuesFrom(parsed.error.issues),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
ok: true,
|
|
260
|
+
issues: validatePlugins(parsed.data, this.options.getPlugins()),
|
|
261
|
+
config: parsed.data,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
apply(value: unknown, baseHash?: string): Promise<ConfigApplyResult> {
|
|
266
|
+
return this.serialize(async () => {
|
|
267
|
+
const document = await this.read()
|
|
268
|
+
|
|
269
|
+
if (baseHash !== undefined && baseHash !== document.hash) {
|
|
270
|
+
throw new ConfigStoreError(
|
|
271
|
+
409,
|
|
272
|
+
'配置文件已被外部修改,请刷新后重试'
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const merged = mergeSecrets(document.config, value)
|
|
277
|
+
const parsed = appConfigSchema.safeParse(merged)
|
|
278
|
+
|
|
279
|
+
if (!parsed.success) {
|
|
280
|
+
throw new ConfigStoreError(
|
|
281
|
+
400,
|
|
282
|
+
'配置校验失败',
|
|
283
|
+
issuesFrom(parsed.error.issues)
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const pluginIssues = validatePlugins(
|
|
288
|
+
parsed.data,
|
|
289
|
+
this.options.getPlugins()
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if (pluginIssues.length > 0) {
|
|
293
|
+
throw new ConfigStoreError(
|
|
294
|
+
400,
|
|
295
|
+
'插件配置校验失败',
|
|
296
|
+
pluginIssues
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const serialized = `${JSON.stringify(parsed.data, null, 4)}\n`
|
|
301
|
+
|
|
302
|
+
await atomicWrite(this.options.configPath, serialized)
|
|
303
|
+
|
|
304
|
+
if (await this.options.restart()) {
|
|
305
|
+
const after = await this.read()
|
|
306
|
+
|
|
307
|
+
return { ok: true, hash: after.hash }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
await restoreRaw(this.options.configPath, document.raw)
|
|
311
|
+
|
|
312
|
+
const restored = await this.options.restart()
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
error: restored
|
|
317
|
+
? '运行时重启失败,已恢复旧配置'
|
|
318
|
+
: '运行时重启失败,已恢复旧配置,但恢复重启也未成功',
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private serialize<T>(task: () => Promise<T>): Promise<T> {
|
|
324
|
+
const run = this.tail.then(task, task)
|
|
325
|
+
|
|
326
|
+
this.tail = run.then(
|
|
327
|
+
() => undefined,
|
|
328
|
+
() => undefined
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return run
|
|
332
|
+
}
|
|
333
|
+
}
|