ework-web 0.3.0 → 0.4.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/package.json +1 -1
- package/src/db-admin.ts +566 -0
- package/src/index.ts +160 -1
- package/src/static/db-wizard.js +106 -0
- package/src/views/settings.ts +40 -0
package/package.json
CHANGED
package/src/db-admin.ts
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
// DB-backend UI — Phase 1 (pure logic, no HTTP).
|
|
2
|
+
//
|
|
3
|
+
// Three primitives for the migration wizard: testMysqlConnection (probe a
|
|
4
|
+
// target without side effects), migrateSqliteToMysql (copy every table from
|
|
5
|
+
// the running sqlite driver into a throwaway mysql pool), writeMysqlEnv
|
|
6
|
+
// (forward-fill .env). Design spec: docs/db-backend-ui.md §4.1 + §4.3.
|
|
7
|
+
//
|
|
8
|
+
// The throwaway-pool pattern is load-bearing: every function opens a NEW
|
|
9
|
+
// mysql2 pool with caller-supplied creds and closes it before returning. The
|
|
10
|
+
// running driver (getDB(), opened once at boot) is never touched, so the
|
|
11
|
+
// wizard can fail/retry without affecting live traffic.
|
|
12
|
+
|
|
13
|
+
import { createPool, type Pool, type PoolConnection } from "mysql2/promise";
|
|
14
|
+
import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { getDB } from "./db";
|
|
18
|
+
|
|
19
|
+
export interface MysqlTargetOpts {
|
|
20
|
+
host: string;
|
|
21
|
+
port: number;
|
|
22
|
+
user: string;
|
|
23
|
+
password: string;
|
|
24
|
+
database: string;
|
|
25
|
+
/** Optional table prefix for the target. Empty = no prefix. Validated
|
|
26
|
+
* against the same identifier rule as WORK_DB_PREFIX in db.ts so generated
|
|
27
|
+
* SQL can't be SQL-injected via the prefix. */
|
|
28
|
+
prefix?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type TestMysqlResult =
|
|
32
|
+
| { ok: true; serverVersion: string; databaseExists: boolean }
|
|
33
|
+
| { ok: false; error: string; hint?: string };
|
|
34
|
+
|
|
35
|
+
export type MigrateResult =
|
|
36
|
+
| { ok: true; tables: { table: string; rows: number }[] }
|
|
37
|
+
| { ok: false; error: string; partial?: string[] };
|
|
38
|
+
|
|
39
|
+
export type WriteEnvResult = { written: string[]; envPath: string };
|
|
40
|
+
|
|
41
|
+
// FK-safe copy order (parents before children). `config` lives first — it's
|
|
42
|
+
// created in db.ts (not in schema.sql), has no FK deps, and holds the
|
|
43
|
+
// migration wizard's own settings. We additionally SET FOREIGN_KEY_CHECKS=0
|
|
44
|
+
// during the copy as belt-and-suspenders; keeping the array FK-ordered still
|
|
45
|
+
// matches what a manual dump/restore would do.
|
|
46
|
+
//
|
|
47
|
+
// When adding a table, update this list AND schema.sql + schema-mysql.sql —
|
|
48
|
+
// otherwise migrate() silently skips the new table.
|
|
49
|
+
export const MIGRATION_TABLE_ORDER = [
|
|
50
|
+
"config",
|
|
51
|
+
"users",
|
|
52
|
+
"projects",
|
|
53
|
+
"model_cache",
|
|
54
|
+
"issues",
|
|
55
|
+
"labels",
|
|
56
|
+
"comments",
|
|
57
|
+
"issue_labels",
|
|
58
|
+
"reactions",
|
|
59
|
+
"attachments",
|
|
60
|
+
"webhooks",
|
|
61
|
+
"personal_access_tokens",
|
|
62
|
+
"project_members",
|
|
63
|
+
"webhook_deliveries",
|
|
64
|
+
] as const;
|
|
65
|
+
|
|
66
|
+
/** Rewrite {{token}} -> prefix+token. Mirrors db.ts:applyPrefix but takes a
|
|
67
|
+
* caller-supplied prefix — the target may differ from the running driver's
|
|
68
|
+
* (module-private) prefix, and importing DB_PREFIX would couple this module
|
|
69
|
+
* to the boot-time env. */
|
|
70
|
+
function applyTargetPrefix(sql: string, prefix: string): string {
|
|
71
|
+
if (!sql.includes("{{")) return sql;
|
|
72
|
+
return sql.replace(/\{\{(\w+)\}\}/g, (_m, name: string) => prefix + name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validatePrefix(p: string): string {
|
|
76
|
+
const v = (p ?? "").trim();
|
|
77
|
+
if (v && !/^[A-Za-z_][A-Za-z0-9_]{0,31}$/.test(v)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Invalid DB prefix "${v}": must match ^[A-Za-z_][A-Za-z0-9_]{0,31}$`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return v;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Redact password-shaped substrings from connection-string-like output.
|
|
86
|
+
* Covers URL-style `://user:pw@host`, querystring `?password=...` /
|
|
87
|
+
* `&password=...`, and shell `password='...'` / `password="..."`. */
|
|
88
|
+
function redactPassword(s: string): string {
|
|
89
|
+
return s
|
|
90
|
+
.replace(/(:\/\/[^:/@]*):[^@/]*@/g, "$1:***@")
|
|
91
|
+
.replace(/[?&]password=[^&\s]*/gi, (m) => m.charAt(0) + "password=***")
|
|
92
|
+
.replace(/password\s*=\s*'[^']*'/gi, "password='***'")
|
|
93
|
+
.replace(/password\s*=\s*"[^"]*"/gi, 'password="***"');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function chunk<T>(arr: readonly T[], size: number): T[][] {
|
|
97
|
+
if (size < 1) throw new Error("chunk size must be >= 1");
|
|
98
|
+
const out: T[][] = [];
|
|
99
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
100
|
+
out.push(arr.slice(i, i + size) as T[]);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Strip `--` comment lines BEFORE splitting on `;` — comment lines may
|
|
106
|
+
* contain `;` and corrupt the split (same trick as db.ts:MysqlDriver.create). */
|
|
107
|
+
function readSchemaMysqlStatements(prefix: string): string[] {
|
|
108
|
+
const raw = readFileSync(join(import.meta.dir, "schema-mysql.sql"), "utf8");
|
|
109
|
+
const stripped = raw
|
|
110
|
+
.split("\n")
|
|
111
|
+
.filter((l) => !l.trimStart().startsWith("--"))
|
|
112
|
+
.join("\n");
|
|
113
|
+
const out: string[] = [];
|
|
114
|
+
for (const stmt of stripped.split(";").map((s) => s.trim()).filter((s) => s.length > 0)) {
|
|
115
|
+
out.push(applyTargetPrefix(stmt, prefix));
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hintForError(msg: string): string | undefined {
|
|
121
|
+
const lc = msg.toLowerCase();
|
|
122
|
+
if (lc.includes("econnrefused") || lc.includes("enotfound") || lc.includes("etimedout")) {
|
|
123
|
+
return "Check host and port; is MySQL running and reachable from this host?";
|
|
124
|
+
}
|
|
125
|
+
if (lc.includes("access denied") || lc.includes("error 1045") || lc.includes("er_access_denied")) {
|
|
126
|
+
return "Bad username or password.";
|
|
127
|
+
}
|
|
128
|
+
if (lc.includes("unknown database") || lc.includes("error 1049")) {
|
|
129
|
+
return "Create the database first, or grant CREATE to the user so migrate() can create it.";
|
|
130
|
+
}
|
|
131
|
+
if (lc.includes("ssl") || lc.includes("tls")) {
|
|
132
|
+
return "TLS/SSL handshake failed — check MySQL's TLS config and the client's ssl mode.";
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Backtick-quote a SQL identifier. Doubles internal backticks per the MySQL
|
|
138
|
+
* escape rule. */
|
|
139
|
+
function ident(name: string): string {
|
|
140
|
+
return "`" + name.replace(/`/g, "``") + "`";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function testMysqlConnection(opts: MysqlTargetOpts): Promise<TestMysqlResult> {
|
|
144
|
+
let pool: Pool | null = null;
|
|
145
|
+
let conn: PoolConnection | null = null;
|
|
146
|
+
try {
|
|
147
|
+
// No database selected at handshake — we may be probing a DB that doesn't
|
|
148
|
+
// exist yet (user has CREATE but hasn't run it). Selecting a non-existent
|
|
149
|
+
// DB at connect time would fail the handshake.
|
|
150
|
+
pool = createPool({
|
|
151
|
+
host: opts.host,
|
|
152
|
+
port: opts.port,
|
|
153
|
+
user: opts.user,
|
|
154
|
+
password: opts.password,
|
|
155
|
+
waitForConnections: true,
|
|
156
|
+
connectionLimit: 2,
|
|
157
|
+
charset: "utf8mb4",
|
|
158
|
+
connectTimeout: 10_000,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
conn = await pool.getConnection();
|
|
162
|
+
// A real query, NOT mysqladmin ping — the mysql:8.0 entrypoint races a
|
|
163
|
+
// temp socket-only server during init; ping succeeds during the temp
|
|
164
|
+
// phase and races the restart. See scripts/test-mysql.sh.
|
|
165
|
+
const [versionRowsRaw] = await conn.query("SELECT VERSION() AS v");
|
|
166
|
+
const versionRows = versionRowsRaw as { v: string }[];
|
|
167
|
+
const version0 = versionRows[0];
|
|
168
|
+
const serverVersion = version0?.v ?? "unknown";
|
|
169
|
+
|
|
170
|
+
const [dbRowsRaw] = await conn.query(
|
|
171
|
+
"SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?",
|
|
172
|
+
[opts.database]
|
|
173
|
+
);
|
|
174
|
+
const dbRows = dbRowsRaw as { SCHEMA_NAME: string }[];
|
|
175
|
+
const databaseExists = dbRows.length > 0;
|
|
176
|
+
|
|
177
|
+
// Best-effort privilege check. We can't cleanly ask "do I have CREATE on
|
|
178
|
+
// a not-yet-existent DB?" without parsing SHOW GRANTS (the GRANT syntax
|
|
179
|
+
// has many shapes). If the DB doesn't exist, require CREATE anywhere in
|
|
180
|
+
// the user's grants. False negatives are fine — the user can create the
|
|
181
|
+
// DB by hand; false positives get caught at migrate().
|
|
182
|
+
let hasCreate = true;
|
|
183
|
+
if (!databaseExists) {
|
|
184
|
+
const [grantsRaw] = await conn.query("SHOW GRANTS FOR CURRENT_USER()");
|
|
185
|
+
const grantsRows = grantsRaw as Record<string, string>[];
|
|
186
|
+
const grants = grantsRows
|
|
187
|
+
.map((r) => Object.values(r)[0])
|
|
188
|
+
.filter((v): v is string => typeof v === "string");
|
|
189
|
+
hasCreate = grants.some((g) => /\bCREATE\b/i.test(g));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!databaseExists && !hasCreate) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
error: `Database "${opts.database}" does not exist and user "${opts.user}" has no CREATE privilege`,
|
|
196
|
+
hint: "Create the database first (CREATE DATABASE ... CHARACTER SET utf8mb4) or grant CREATE on it to the user.",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { ok: true, serverVersion, databaseExists };
|
|
201
|
+
} catch (e) {
|
|
202
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
error: redactPassword(msg),
|
|
206
|
+
hint: hintForError(msg),
|
|
207
|
+
};
|
|
208
|
+
} finally {
|
|
209
|
+
if (conn) {
|
|
210
|
+
try { conn.release(); } catch (e) { console.warn("[db-admin] conn.release failed:", e); }
|
|
211
|
+
}
|
|
212
|
+
if (pool) {
|
|
213
|
+
try { await pool.end(); } catch (e) { console.warn("[db-admin] pool.end failed:", e); }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function migrateSqliteToMysql(opts: MysqlTargetOpts): Promise<MigrateResult> {
|
|
219
|
+
const prefix = validatePrefix(opts.prefix ?? "");
|
|
220
|
+
let pool: Pool | null = null;
|
|
221
|
+
let conn: PoolConnection | null = null;
|
|
222
|
+
const completed: { table: string; rows: number }[] = [];
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
// Pool WITHOUT `database`: if the target DB doesn't exist yet (the common
|
|
226
|
+
// migration-wizard case), selecting it at handshake would fail with
|
|
227
|
+
// "Unknown database". We CREATE + USE on a single held connection below.
|
|
228
|
+
//
|
|
229
|
+
// Holding ONE connection for the whole migrate() is load-bearing:
|
|
230
|
+
// USE, FOREIGN_KEY_CHECKS, and transactions are all session-scoped. With
|
|
231
|
+
// a pool, sequential pool.query() calls can land on different underlying
|
|
232
|
+
// connections, so session state set on one wouldn't apply to the next.
|
|
233
|
+
// One held connection makes that state deterministic.
|
|
234
|
+
pool = createPool({
|
|
235
|
+
host: opts.host,
|
|
236
|
+
port: opts.port,
|
|
237
|
+
user: opts.user,
|
|
238
|
+
password: opts.password,
|
|
239
|
+
waitForConnections: true,
|
|
240
|
+
connectionLimit: 2,
|
|
241
|
+
charset: "utf8mb4",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
conn = await pool.getConnection();
|
|
245
|
+
|
|
246
|
+
await conn.query(
|
|
247
|
+
`CREATE DATABASE IF NOT EXISTS ${ident(opts.database)} CHARACTER SET utf8mb4`
|
|
248
|
+
);
|
|
249
|
+
await conn.query(`USE ${ident(opts.database)}`);
|
|
250
|
+
|
|
251
|
+
// The `config` table is created in db.ts:SqliteDriver.create at boot but
|
|
252
|
+
// is missing from schema-mysql.sql (pre-existing gap — MysqlDriver.create
|
|
253
|
+
// doesn't create it either, so a plain mysql boot has no config table).
|
|
254
|
+
// Recreate the same shape here so migrate() can copy config rows. `key`
|
|
255
|
+
// is a MySQL reserved word — must be backticked. VARCHAR(255) on the PK
|
|
256
|
+
// because MySQL TEXT can't be a PRIMARY KEY without a prefix length.
|
|
257
|
+
await conn.query(
|
|
258
|
+
applyTargetPrefix(
|
|
259
|
+
"CREATE TABLE IF NOT EXISTS {{config}} (" +
|
|
260
|
+
"`key` VARCHAR(255) PRIMARY KEY," +
|
|
261
|
+
"value TEXT NOT NULL," +
|
|
262
|
+
"updated_at VARCHAR(40) NOT NULL" +
|
|
263
|
+
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
|
264
|
+
prefix
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// Apply schema-mysql.sql. CREATE INDEX lacks IF NOT EXISTS in MySQL, so
|
|
269
|
+
// tolerate ER_DUP_KEYNAME (1061) for re-runs.
|
|
270
|
+
for (const stmt of readSchemaMysqlStatements(prefix)) {
|
|
271
|
+
try {
|
|
272
|
+
await conn.query(stmt);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
if (
|
|
275
|
+
e && typeof e === "object" && "errno" in e &&
|
|
276
|
+
(e as { errno: number }).errno === 1061
|
|
277
|
+
) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
throw e;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Idempotent re-run: TRUNCATE resets data + AUTO_INCREMENT. FK_CHECKS=0
|
|
285
|
+
// lets us TRUNCATE tables that are referenced by others (TRUNCATE
|
|
286
|
+
// otherwise refuses even on empty data). Stays 0 for the whole copy
|
|
287
|
+
// phase; reset to 1 once every table has been inserted + verified.
|
|
288
|
+
await conn.query("SET FOREIGN_KEY_CHECKS = 0");
|
|
289
|
+
for (const t of MIGRATION_TABLE_ORDER) {
|
|
290
|
+
await conn.query(`TRUNCATE TABLE ${ident(prefix + t)}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const sqlite = getDB();
|
|
294
|
+
for (const table of MIGRATION_TABLE_ORDER) {
|
|
295
|
+
const rows = await sqlite.all<Record<string, unknown>>(
|
|
296
|
+
`SELECT * FROM {{${table}}}`
|
|
297
|
+
);
|
|
298
|
+
if (rows.length === 0) {
|
|
299
|
+
completed.push({ table, rows: 0 });
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const first = rows[0];
|
|
303
|
+
if (!first) {
|
|
304
|
+
// Unreachable given length>0; noUncheckedIndexedAccess forces the guard.
|
|
305
|
+
completed.push({ table, rows: 0 });
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const cols = Object.keys(first);
|
|
309
|
+
if (cols.length === 0) {
|
|
310
|
+
completed.push({ table, rows: rows.length });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// `INSERT INTO t (a,b) VALUES ?` with a 2D array — mysql2 expands the
|
|
315
|
+
// nested arrays into a comma-separated value list. undefined → null
|
|
316
|
+
// (sqlite rows shouldn't have undefined fields, but JS-side defaults
|
|
317
|
+
// sometimes do; mysql2 rejects undefined).
|
|
318
|
+
const insertSql =
|
|
319
|
+
`INSERT INTO ${ident(prefix + table)}` +
|
|
320
|
+
` (${cols.map(ident).join(",")}) VALUES ?`;
|
|
321
|
+
|
|
322
|
+
// Per-table transaction: a bad batch rolls back THIS table's inserts
|
|
323
|
+
// without touching earlier tables. (DDL above already auto-committed;
|
|
324
|
+
// only the INSERTs are transactional.)
|
|
325
|
+
try {
|
|
326
|
+
await conn.beginTransaction();
|
|
327
|
+
for (const batch of chunk(rows, 500)) {
|
|
328
|
+
const matrix: unknown[][] = batch.map((r) =>
|
|
329
|
+
cols.map((c) => {
|
|
330
|
+
const v = r[c];
|
|
331
|
+
return v === undefined ? null : v;
|
|
332
|
+
})
|
|
333
|
+
);
|
|
334
|
+
await conn.query(insertSql, [matrix]);
|
|
335
|
+
}
|
|
336
|
+
await conn.commit();
|
|
337
|
+
} catch (e) {
|
|
338
|
+
try { await conn.rollback(); } catch (rollbackErr) {
|
|
339
|
+
console.warn("[db-admin] rollback failed for", table, ":", rollbackErr);
|
|
340
|
+
}
|
|
341
|
+
throw e;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const [cntRowsRaw] = await conn.query(
|
|
345
|
+
`SELECT COUNT(*) AS c FROM ${ident(prefix + table)}`
|
|
346
|
+
);
|
|
347
|
+
const cntRows = cntRowsRaw as { c: number }[];
|
|
348
|
+
const cnt0 = cntRows[0];
|
|
349
|
+
const mysqlCount = cnt0?.c ?? -1;
|
|
350
|
+
if (mysqlCount !== rows.length) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`Row-count mismatch for ${prefix}${table}: sqlite=${rows.length} mysql=${mysqlCount}`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
completed.push({ table, rows: rows.length });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
await conn.query("SET FOREIGN_KEY_CHECKS = 1");
|
|
359
|
+
return { ok: true, tables: completed };
|
|
360
|
+
} catch (e) {
|
|
361
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
362
|
+
// DDL above auto-committed (MySQL implicit commit on DDL), so ROLLBACK
|
|
363
|
+
// wouldn't undo CREATE TABLE. Explicitly DROP every half-built table so
|
|
364
|
+
// the next migrate() call starts from a clean target.
|
|
365
|
+
if (conn) {
|
|
366
|
+
try {
|
|
367
|
+
await conn.query("SET FOREIGN_KEY_CHECKS = 0");
|
|
368
|
+
for (const t of MIGRATION_TABLE_ORDER) {
|
|
369
|
+
await conn.query(`DROP TABLE IF EXISTS ${ident(prefix + t)}`);
|
|
370
|
+
}
|
|
371
|
+
await conn.query("SET FOREIGN_KEY_CHECKS = 1");
|
|
372
|
+
} catch (cleanupErr) {
|
|
373
|
+
// The migrate() failure is the primary signal. A cleanup failure
|
|
374
|
+
// leaves stale tables; the next migrate() run will TRUNCATE them.
|
|
375
|
+
console.warn("[db-admin] cleanup DROP TABLES failed:", cleanupErr);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
ok: false,
|
|
380
|
+
error: redactPassword(msg),
|
|
381
|
+
partial: completed.map((c) => `${c.table}=${c.rows}`),
|
|
382
|
+
};
|
|
383
|
+
} finally {
|
|
384
|
+
if (conn) {
|
|
385
|
+
try { conn.release(); } catch (e) { console.warn("[db-admin] conn.release failed:", e); }
|
|
386
|
+
}
|
|
387
|
+
if (pool) {
|
|
388
|
+
try { await pool.end(); } catch (e) { console.warn("[db-admin] pool.end failed:", e); }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export async function writeMysqlEnv(
|
|
394
|
+
envPath: string,
|
|
395
|
+
opts: MysqlTargetOpts
|
|
396
|
+
): Promise<WriteEnvResult> {
|
|
397
|
+
const prefix = validatePrefix(opts.prefix ?? "");
|
|
398
|
+
|
|
399
|
+
// PASSWORD is written verbatim — the .env file is mode 0600 (deploy.sh
|
|
400
|
+
// enforces) and the existing .env format is bare KEY=VALUE (see
|
|
401
|
+
// .env.example). If a password contains '#' or whitespace, the user must
|
|
402
|
+
// hand-quote; we don't try to be smart about shell quoting because Bun's
|
|
403
|
+
// env loader / systemd EnvironmentFile is the source of truth.
|
|
404
|
+
const desired: Record<string, string> = {
|
|
405
|
+
WORK_DB_DRIVER: "mysql",
|
|
406
|
+
WORK_DB_HOST: opts.host,
|
|
407
|
+
WORK_DB_PORT: String(opts.port),
|
|
408
|
+
WORK_DB_USER: opts.user,
|
|
409
|
+
WORK_DB_PASSWORD: opts.password,
|
|
410
|
+
WORK_DB_NAME: opts.database,
|
|
411
|
+
};
|
|
412
|
+
if (prefix) desired.WORK_DB_PREFIX = prefix;
|
|
413
|
+
|
|
414
|
+
const linesIn = existsSync(envPath)
|
|
415
|
+
? readFileSync(envPath, "utf8").split(/\r?\n/)
|
|
416
|
+
: [];
|
|
417
|
+
|
|
418
|
+
const written: string[] = [];
|
|
419
|
+
const seen = new Set<string>();
|
|
420
|
+
const out: string[] = [];
|
|
421
|
+
|
|
422
|
+
// First pass: update existing keys in place (preserves comments + order).
|
|
423
|
+
// Matches the project's .env style: `KEY=VALUE`, no surrounding quotes.
|
|
424
|
+
for (const line of linesIn) {
|
|
425
|
+
const m = line.match(/^([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/);
|
|
426
|
+
if (m) {
|
|
427
|
+
const key = m[1] ?? "";
|
|
428
|
+
if (key in desired) {
|
|
429
|
+
out.push(`${key}=${desired[key] ?? ""}`);
|
|
430
|
+
written.push(key);
|
|
431
|
+
seen.add(key);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
out.push(line);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Second pass: append missing keys under a legible banner.
|
|
439
|
+
const missing = Object.keys(desired).filter((k) => !seen.has(k));
|
|
440
|
+
if (missing.length > 0) {
|
|
441
|
+
if (out.length > 0 && out[out.length - 1] !== "") out.push("");
|
|
442
|
+
out.push("# ework-web: MySQL backend (written by db-admin migration)");
|
|
443
|
+
for (const k of missing) {
|
|
444
|
+
out.push(`${k}=${desired[k] ?? ""}`);
|
|
445
|
+
written.push(k);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
writeFileSync(envPath, out.join("\n") + "\n", "utf8");
|
|
450
|
+
return { written, envPath };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Reverse migration: MySQL → SQLite. Creates a fresh SQLite file at targetPath,
|
|
454
|
+
// applies schema.sql, copies all 14 tables from the running MySQL driver.
|
|
455
|
+
// Used as the safety net when the MySQL switch goes wrong.
|
|
456
|
+
export async function migrateMysqlToSqlite(targetPath: string): Promise<MigrateResult> {
|
|
457
|
+
const target = new Database(targetPath, { create: true, readwrite: true });
|
|
458
|
+
const completed: { table: string; rows: number }[] = [];
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
target.exec("PRAGMA journal_mode = WAL");
|
|
462
|
+
target.exec("PRAGMA foreign_keys = ON");
|
|
463
|
+
|
|
464
|
+
// config table (same shape as db.ts:144 — not in schema.sql).
|
|
465
|
+
// `key` is NOT reserved in SQLite (unlike MySQL).
|
|
466
|
+
target.exec(
|
|
467
|
+
applyTargetPrefix(
|
|
468
|
+
"CREATE TABLE IF NOT EXISTS {{config}} (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL)",
|
|
469
|
+
""
|
|
470
|
+
)
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
// schema.sql — bun:sqlite's exec handles the whole file (comments + multi-statement).
|
|
474
|
+
const schema = readFileSync(join(import.meta.dir, "schema.sql"), "utf8");
|
|
475
|
+
target.exec(applyTargetPrefix(schema, ""));
|
|
476
|
+
|
|
477
|
+
const source = getDB();
|
|
478
|
+
for (const table of MIGRATION_TABLE_ORDER) {
|
|
479
|
+
const rows = await source.all<Record<string, unknown>>(`SELECT * FROM {{${table}}}`);
|
|
480
|
+
if (rows.length === 0) {
|
|
481
|
+
completed.push({ table, rows: 0 });
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const first = rows[0];
|
|
485
|
+
if (!first) {
|
|
486
|
+
completed.push({ table, rows: 0 });
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const cols = Object.keys(first);
|
|
490
|
+
if (cols.length === 0) {
|
|
491
|
+
completed.push({ table, rows: rows.length });
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const placeholders = cols.map(() => "?").join(",");
|
|
496
|
+
const insertSql = `INSERT INTO ${table} (${cols.join(",")}) VALUES (${placeholders})`;
|
|
497
|
+
|
|
498
|
+
target.transaction(() => {
|
|
499
|
+
for (const row of rows) {
|
|
500
|
+
const vals = cols.map((c) => {
|
|
501
|
+
const v = (row as Record<string, unknown>)[c];
|
|
502
|
+
return v === undefined ? null : v;
|
|
503
|
+
});
|
|
504
|
+
target.query(insertSql).run(...(vals as SQLQueryBindings[]));
|
|
505
|
+
}
|
|
506
|
+
})();
|
|
507
|
+
|
|
508
|
+
const countRow = target.query(`SELECT COUNT(*) AS c FROM ${table}`).get() as { c: number } | null;
|
|
509
|
+
const count = countRow?.c ?? -1;
|
|
510
|
+
if (count !== rows.length) {
|
|
511
|
+
throw new Error(`Row-count mismatch for ${table}: mysql=${rows.length} sqlite=${count}`);
|
|
512
|
+
}
|
|
513
|
+
completed.push({ table, rows: rows.length });
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
target.close();
|
|
517
|
+
return { ok: true, tables: completed };
|
|
518
|
+
} catch (e) {
|
|
519
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
520
|
+
try { target.close(); } catch { /* already closed */ }
|
|
521
|
+
try { unlinkSync(targetPath); } catch { /* file may not exist */ }
|
|
522
|
+
return { ok: false, error: redactPassword(msg), partial: completed.map((c) => `${c.table}=${c.rows}`) };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export async function writeSqliteEnv(envPath: string, dbPath: string): Promise<WriteEnvResult> {
|
|
527
|
+
const desired: Record<string, string> = {
|
|
528
|
+
WORK_DB_DRIVER: "sqlite",
|
|
529
|
+
WORK_DB_PATH: dbPath,
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const linesIn = existsSync(envPath)
|
|
533
|
+
? readFileSync(envPath, "utf8").split(/\r?\n/)
|
|
534
|
+
: [];
|
|
535
|
+
|
|
536
|
+
const written: string[] = [];
|
|
537
|
+
const seen = new Set<string>();
|
|
538
|
+
const out: string[] = [];
|
|
539
|
+
|
|
540
|
+
for (const line of linesIn) {
|
|
541
|
+
const m = line.match(/^([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/);
|
|
542
|
+
if (m) {
|
|
543
|
+
const key = m[1] ?? "";
|
|
544
|
+
if (key in desired) {
|
|
545
|
+
out.push(`${key}=${desired[key] ?? ""}`);
|
|
546
|
+
written.push(key);
|
|
547
|
+
seen.add(key);
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
out.push(line);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const missing = Object.keys(desired).filter((k) => !seen.has(k));
|
|
555
|
+
if (missing.length > 0) {
|
|
556
|
+
if (out.length > 0 && out[out.length - 1] !== "") out.push("");
|
|
557
|
+
out.push("# ework-web: revert to SQLite (written by db-admin migration)");
|
|
558
|
+
for (const k of missing) {
|
|
559
|
+
out.push(`${k}=${desired[k] ?? ""}`);
|
|
560
|
+
written.push(k);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
writeFileSync(envPath, out.join("\n") + "\n", "utf8");
|
|
565
|
+
return { written, envPath };
|
|
566
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { join, dirname } from "path";
|
|
2
2
|
import { fileURLToPath } from "url";
|
|
3
|
-
import { readFileSync, appendFileSync } from "fs";
|
|
3
|
+
import { readFileSync, appendFileSync, existsSync } from "fs";
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
import { homedir } from "os";
|
|
4
6
|
import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./config";
|
|
5
7
|
import type { Config } from "./config";
|
|
6
8
|
import { setConfig, initDB } from "./db";
|
|
9
|
+
import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv } from "./db-admin";
|
|
10
|
+
import type { MysqlTargetOpts } from "./db-admin";
|
|
7
11
|
import { checkAuth, makeAuthCookieHeader, clearAuthCookieHeader, loginHTML, sanitizeNext, ensureBootstrapAdmin, ensureBootstrapSystem, isReservedSystemLogin } from "./auth";
|
|
8
12
|
import { OpencodeClient, OpencodeError } from "./opencode";
|
|
9
13
|
import { renderMarkdown } from "./render/markdown";
|
|
@@ -172,6 +176,98 @@ function html(body: string, status = 200): Response {
|
|
|
172
176
|
});
|
|
173
177
|
}
|
|
174
178
|
|
|
179
|
+
// The prefix regex below must match db.ts:WORK_DB_PREFIX exactly — if the API
|
|
180
|
+
// accepts a prefix that boot rejects, the wizard writes a .env that restarts
|
|
181
|
+
// into a crash. Any change here must be mirrored in db.ts + db-admin.ts.
|
|
182
|
+
function parseDbTargetOpts(payload: unknown): MysqlTargetOpts | { error: string } {
|
|
183
|
+
if (typeof payload !== "object" || payload === null) return { error: "invalid body" };
|
|
184
|
+
const p = payload as Record<string, unknown>;
|
|
185
|
+
const host = typeof p.host === "string" ? p.host.trim() : "";
|
|
186
|
+
const portNum = Number(p.port);
|
|
187
|
+
const user = typeof p.user === "string" ? p.user.trim() : "";
|
|
188
|
+
const password = typeof p.password === "string" ? p.password : "";
|
|
189
|
+
const database = typeof p.database === "string" ? p.database.trim() : "";
|
|
190
|
+
const prefix = typeof p.prefix === "string" ? p.prefix.trim() : "";
|
|
191
|
+
if (!host) return { error: "host required" };
|
|
192
|
+
if (!user) return { error: "user required" };
|
|
193
|
+
if (!database) return { error: "database required" };
|
|
194
|
+
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
|
195
|
+
return { error: "port must be an integer 1-65535" };
|
|
196
|
+
}
|
|
197
|
+
if (prefix && !/^[A-Za-z_][A-Za-z0-9_]{0,31}$/.test(prefix)) {
|
|
198
|
+
return { error: "prefix must match ^[A-Za-z_][A-Za-z0-9_]{0,31}$" };
|
|
199
|
+
}
|
|
200
|
+
const opts: MysqlTargetOpts = { host, port: portNum, user, password, database };
|
|
201
|
+
if (prefix) opts.prefix = prefix;
|
|
202
|
+
return opts;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Two restart paths depending on how this process was launched. Both run
|
|
206
|
+
// AFTER the HTTP response is sent (caller schedules this last):
|
|
207
|
+
// systemd (INVOCATION_ID set): exit → the unit's Restart=always brings us back
|
|
208
|
+
// PID-file mode (ework-aio start): `ework-aio restart web` SIGTERMs us via the
|
|
209
|
+
// pidfile and spawns a fresh process that re-reads the updated .env.
|
|
210
|
+
// The ework-aio spawn is UNSAFE under systemd — it would start a second process
|
|
211
|
+
// on the same port — so the INVOCATION_ID gate is load-bearing.
|
|
212
|
+
function scheduleRestart(): void {
|
|
213
|
+
if (process.env.INVOCATION_ID) {
|
|
214
|
+
setTimeout(() => process.exit(0), 750);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const child = spawn("ework-aio", ["restart", "web"], {
|
|
218
|
+
detached: true,
|
|
219
|
+
stdio: "ignore",
|
|
220
|
+
});
|
|
221
|
+
child.on("error", () => {
|
|
222
|
+
// ework-aio not on PATH (dev mode) — best-effort exit; if no supervisor
|
|
223
|
+
// is watching, the admin restarts manually.
|
|
224
|
+
setTimeout(() => process.exit(0), 750);
|
|
225
|
+
});
|
|
226
|
+
child.unref();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The daemon's `issues` table (thin ref) collides with the web's `issues`
|
|
230
|
+
// table (full content) if they share a database. The daemon MUST use a
|
|
231
|
+
// different prefix. We append "d_" to whatever the web uses so both can
|
|
232
|
+
// coexist in one database without the operator having to plan prefixes.
|
|
233
|
+
function daemonPrefix(webPrefix: string): string {
|
|
234
|
+
return webPrefix + "d_";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function daemonEnvPath(): string | null {
|
|
238
|
+
const dataDir = process.env.WORK_DAEMON_DATA_DIR;
|
|
239
|
+
if (dataDir) {
|
|
240
|
+
const p = join(dataDir, ".env");
|
|
241
|
+
return existsSync(p) ? p : null;
|
|
242
|
+
}
|
|
243
|
+
const conventional = join(homedir(), ".local", "share", "ework-daemon", ".env");
|
|
244
|
+
return existsSync(conventional) ? conventional : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function spawnDaemonRestart(): void {
|
|
248
|
+
try {
|
|
249
|
+
const child = spawn("ework-aio", ["restart", "daemon"], { detached: true, stdio: "ignore" });
|
|
250
|
+
child.on("error", () => {});
|
|
251
|
+
child.unref();
|
|
252
|
+
} catch {
|
|
253
|
+
// best-effort — the manual fallback path covers this
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function daemonManualInstructions(opts: MysqlTargetOpts): string {
|
|
258
|
+
return [
|
|
259
|
+
"将以下内容追加到 daemon 的 .env,然后运行 ework-aio restart daemon:",
|
|
260
|
+
"",
|
|
261
|
+
`WORK_DB_DRIVER=mysql`,
|
|
262
|
+
`WORK_DB_HOST=${opts.host}`,
|
|
263
|
+
`WORK_DB_PORT=${opts.port}`,
|
|
264
|
+
`WORK_DB_USER=${opts.user}`,
|
|
265
|
+
"WORK_DB_PASSWORD=<daemon密码>",
|
|
266
|
+
`WORK_DB_NAME=${opts.database}`,
|
|
267
|
+
`WORK_DB_PREFIX=${opts.prefix ?? ""}`,
|
|
268
|
+
].join("\n");
|
|
269
|
+
}
|
|
270
|
+
|
|
175
271
|
const REPO_ISSUE_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)$/;
|
|
176
272
|
const REPO_LIST_RE = /^\/([^/]+)\/([^/]+)\/issues$/;
|
|
177
273
|
const REPO_NEW_RE = /^\/([^/]+)\/([^/]+)\/issues\/new$/;
|
|
@@ -274,6 +370,7 @@ function chunkTextTTS(text: string, max = 120): string[] {
|
|
|
274
370
|
|
|
275
371
|
async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean; user: UserRow | null }): Promise<Response> {
|
|
276
372
|
if (url.pathname === "/static/app.js") return staticAsset("app.js", "text/javascript; charset=utf-8", req);
|
|
373
|
+
if (url.pathname === "/static/db-wizard.js") return staticAsset("db-wizard.js", "text/javascript; charset=utf-8", req);
|
|
277
374
|
if (url.pathname === "/static/session.js") return staticAsset("session.js", "text/javascript; charset=utf-8", req);
|
|
278
375
|
if (url.pathname === "/static/file.js") return staticAsset("file.js", "text/javascript; charset=utf-8", req);
|
|
279
376
|
if (url.pathname === "/static/tts.js") return staticAsset("tts.js", "text/javascript; charset=utf-8", req);
|
|
@@ -605,6 +702,68 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
605
702
|
return new Response(stream, { status: 200, headers });
|
|
606
703
|
}
|
|
607
704
|
|
|
705
|
+
if (req.method === "POST" && url.pathname === "/api/db/test") {
|
|
706
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
707
|
+
if (!rateLimit(`db-test:${ip}`, 5, 5 / 60)) return json({ error: "rate limited" }, 429);
|
|
708
|
+
const payload = await req.json().catch(() => ({}));
|
|
709
|
+
const parsed = parseDbTargetOpts(payload);
|
|
710
|
+
if ("error" in parsed) return json(parsed, 400);
|
|
711
|
+
const result = await testMysqlConnection(parsed);
|
|
712
|
+
return json(result, result.ok ? 200 : 422);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (req.method === "POST" && url.pathname === "/api/db/migrate") {
|
|
716
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
717
|
+
if (!rateLimit(`db-migrate:${ip}`, 2, 2 / 3600)) return json({ error: "rate limited" }, 429);
|
|
718
|
+
const payload = await req.json().catch(() => ({}));
|
|
719
|
+
const parsed = parseDbTargetOpts(payload);
|
|
720
|
+
if ("error" in parsed) return json(parsed, 400);
|
|
721
|
+
const result = await migrateSqliteToMysql(parsed);
|
|
722
|
+
return json(result, result.ok ? 200 : 422);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (req.method === "POST" && url.pathname === "/api/db/enable") {
|
|
726
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
727
|
+
if (!rateLimit(`db-enable:${ip}`, 3, 3 / 3600)) return json({ error: "rate limited" }, 429);
|
|
728
|
+
const payload = await req.json().catch(() => ({}));
|
|
729
|
+
const parsed = parseDbTargetOpts(payload);
|
|
730
|
+
if ("error" in parsed) return json(parsed, 400);
|
|
731
|
+
const envResult = await writeMysqlEnv(join(process.cwd(), ".env"), parsed);
|
|
732
|
+
scheduleRestart();
|
|
733
|
+
return json({ ok: true, ...envResult, restarting: true });
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (req.method === "POST" && url.pathname === "/api/db/daemon-config") {
|
|
737
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
738
|
+
if (!rateLimit(`db-daemon:${ip}`, 3, 3 / 3600)) return json({ error: "rate limited" }, 429);
|
|
739
|
+
const rawPayload = await req.json().catch(() => ({}));
|
|
740
|
+
const parsed = parseDbTargetOpts(rawPayload);
|
|
741
|
+
if ("error" in parsed) return json(parsed, 400);
|
|
742
|
+
const daemonOpts: MysqlTargetOpts = { ...parsed, prefix: daemonPrefix(parsed.prefix ?? "") };
|
|
743
|
+
const envPath = daemonEnvPath();
|
|
744
|
+
if (!envPath) {
|
|
745
|
+
return json({ ok: false, configured: false, manual: daemonManualInstructions(daemonOpts) });
|
|
746
|
+
}
|
|
747
|
+
try {
|
|
748
|
+
const written = await writeMysqlEnv(envPath, daemonOpts);
|
|
749
|
+
spawnDaemonRestart();
|
|
750
|
+
return json({ ok: true, configured: true, envPath, written: written.written });
|
|
751
|
+
} catch (e) {
|
|
752
|
+
return json({ ok: false, configured: false, error: errMsg(e), manual: daemonManualInstructions(daemonOpts) });
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (req.method === "POST" && url.pathname === "/api/db/revert") {
|
|
757
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
758
|
+
if (!rateLimit(`db-revert:${ip}`, 2, 2 / 3600)) return json({ error: "rate limited" }, 429);
|
|
759
|
+
const targetPath = join(process.cwd(), "ework.sqlite");
|
|
760
|
+
const result = await migrateMysqlToSqlite(targetPath);
|
|
761
|
+
if (!result.ok) return json(result, 422);
|
|
762
|
+
await writeSqliteEnv(join(process.cwd(), ".env"), targetPath);
|
|
763
|
+
scheduleRestart();
|
|
764
|
+
return json({ ...result, targetPath, restarting: true });
|
|
765
|
+
}
|
|
766
|
+
|
|
608
767
|
if (url.pathname === "/settings") {
|
|
609
768
|
if (req.method === "GET") {
|
|
610
769
|
return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, await listCachedModels()).html);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// DB-backend migration wizard (settings page). External file because CSP
|
|
2
|
+
// script-src 'self' blocks inline scripts — see index.ts SEC_HEADERS.
|
|
3
|
+
(function () {
|
|
4
|
+
if (!document.getElementById('db-test')) return;
|
|
5
|
+
var R = document.getElementById('db-result');
|
|
6
|
+
|
|
7
|
+
function get(id) { return document.getElementById(id).value; }
|
|
8
|
+
function gather() {
|
|
9
|
+
return JSON.stringify({
|
|
10
|
+
host: get('db-host').trim(),
|
|
11
|
+
port: Number(get('db-port')),
|
|
12
|
+
user: get('db-user').trim(),
|
|
13
|
+
password: get('db-password'),
|
|
14
|
+
database: get('db-database').trim(),
|
|
15
|
+
prefix: get('db-prefix').trim()
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function setBtns(d) {
|
|
19
|
+
document.querySelectorAll('.db-controls button').forEach(function (b) { b.disabled = d; });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function act(action) {
|
|
23
|
+
if (action === 'enable' && !confirm('确认启用 MySQL 并重启?确保已完成 ① 测试 + ② 迁移。')) return;
|
|
24
|
+
setBtns(true);
|
|
25
|
+
R.className = 'db-result db-loading';
|
|
26
|
+
R.textContent = action === 'enable' ? '正在写入 .env,进程即将重启…' : '处理中…';
|
|
27
|
+
try {
|
|
28
|
+
var res = await fetch('/api/db/' + action, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: gather()
|
|
32
|
+
});
|
|
33
|
+
var data = await res.json();
|
|
34
|
+
if (action === 'enable' && data.ok) {
|
|
35
|
+
R.className = 'db-result db-ok';
|
|
36
|
+
R.textContent = '✓ .env 已写入。进程重启中,等待恢复…';
|
|
37
|
+
setTimeout(poll, 3000);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (action === 'daemon-config') {
|
|
41
|
+
R.className = 'db-result ' + (data.configured ? 'db-ok' : 'db-err');
|
|
42
|
+
R.textContent = data.configured
|
|
43
|
+
? '✓ daemon 已写入 .env 并重启:\n ' + data.envPath
|
|
44
|
+
: '⚠ daemon 无法自动配置(.env 未找到)。请手动操作:\n\n' + (data.manual || data.error || '');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
R.className = 'db-result ' + (data.ok ? 'db-ok' : 'db-err');
|
|
48
|
+
if (!data.ok) {
|
|
49
|
+
var m = '✗ ' + (data.error || '未知错误');
|
|
50
|
+
if (data.hint) m += '\n💡 ' + data.hint;
|
|
51
|
+
R.textContent = m;
|
|
52
|
+
} else if (data.tables) {
|
|
53
|
+
R.textContent = '✓ 迁移完成(' + data.tables.length + ' 张表):\n' +
|
|
54
|
+
data.tables.map(function (t) { return ' ' + t.table + ': ' + t.rows + ' 行'; }).join('\n');
|
|
55
|
+
} else if (data.serverVersion) {
|
|
56
|
+
R.textContent = '✓ 连接成功 · MySQL ' + data.serverVersion +
|
|
57
|
+
(data.databaseExists ? '' : '(库不存在,迁移时自动创建)');
|
|
58
|
+
}
|
|
59
|
+
} catch (e) {
|
|
60
|
+
R.className = 'db-result db-err';
|
|
61
|
+
R.textContent = '✗ ' + (e.message || String(e));
|
|
62
|
+
} finally {
|
|
63
|
+
if (action !== 'enable') setBtns(false);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function poll() {
|
|
68
|
+
try { await fetch('/'); location.href = '/settings?saved=1'; }
|
|
69
|
+
catch (e) { setTimeout(poll, 2000); }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
document.getElementById('db-test').onclick = function () { act('test'); };
|
|
73
|
+
document.getElementById('db-migrate').onclick = function () { act('migrate'); };
|
|
74
|
+
document.getElementById('db-daemon').onclick = function () { act('daemon-config'); };
|
|
75
|
+
document.getElementById('db-enable').onclick = function () { act('enable'); };
|
|
76
|
+
|
|
77
|
+
var revertBtn = document.getElementById('db-revert');
|
|
78
|
+
if (revertBtn) {
|
|
79
|
+
revertBtn.onclick = async function () {
|
|
80
|
+
if (!confirm('确认切回 SQLite?将创建新的 SQLite 文件并迁移数据,然后重启。')) return;
|
|
81
|
+
var Rr = document.getElementById('db-revert-result');
|
|
82
|
+
Rr.className = 'db-result db-loading';
|
|
83
|
+
Rr.textContent = '正在迁移到 SQLite…';
|
|
84
|
+
try {
|
|
85
|
+
var res = await fetch('/api/db/revert', {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
body: '{}'
|
|
89
|
+
});
|
|
90
|
+
var data = await res.json();
|
|
91
|
+
if (data.ok) {
|
|
92
|
+
Rr.className = 'db-result db-ok';
|
|
93
|
+
Rr.textContent = '✓ 已迁移到 ' + data.targetPath + '。进程重启中…';
|
|
94
|
+
setTimeout(poll, 3000);
|
|
95
|
+
} else {
|
|
96
|
+
Rr.className = 'db-result db-err';
|
|
97
|
+
Rr.textContent = '✗ ' + (data.error || '未知错误') +
|
|
98
|
+
(data.partial ? '\n部分完成: ' + data.partial.join(', ') : '');
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
Rr.className = 'db-result db-err';
|
|
102
|
+
Rr.textContent = '✗ ' + (e.message || String(e));
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
})();
|
package/src/views/settings.ts
CHANGED
|
@@ -38,6 +38,35 @@ function fieldInput(group: SettingGroup, cfg: Config, models: CachedModel[]): st
|
|
|
38
38
|
.join("");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function buildDbSection(viewer: UserRow): string {
|
|
42
|
+
if (viewer.is_admin !== 1) return "";
|
|
43
|
+
const driver = process.env.WORK_DB_DRIVER || "sqlite";
|
|
44
|
+
const isMysql = driver === "mysql";
|
|
45
|
+
const currentTarget = isMysql
|
|
46
|
+
? `${process.env.WORK_DB_HOST ?? "?"}:${process.env.WORK_DB_PORT ?? "3306"}/${process.env.WORK_DB_NAME ?? "?"}`
|
|
47
|
+
: `SQLite · ${process.env.WORK_DB_PATH ?? "默认路径"}`;
|
|
48
|
+
return `<section class="sg db-section">
|
|
49
|
+
<h2>数据库后端</h2>
|
|
50
|
+
<p class="db-badge">当前后端: <strong>${escapeHtml(driver.toUpperCase())}</strong> · ${escapeHtml(currentTarget)}</p>
|
|
51
|
+
<div class="db-warn">⚠ 切换到 MySQL 后 Web 进程会重启并以 MySQL 为存储。若 MySQL 不可达,进程无法启动——需手动编辑 <code>.env</code> 将 <code>WORK_DB_DRIVER</code> 改回 <code>sqlite</code> 才能恢复。流程:先 ① 测试连接、再 ② 迁移数据、最后 ③ 启用。</div>
|
|
52
|
+
<label class="sf"><span>MySQL 主机</span><input type="text" id="db-host" placeholder="127.0.0.1" autocomplete="off"></label>
|
|
53
|
+
<label class="sf"><span>端口</span><input type="number" id="db-port" value="3306" min="1" max="65535" autocomplete="off"></label>
|
|
54
|
+
<label class="sf"><span>用户名</span><input type="text" id="db-user" placeholder="ework" autocomplete="off"></label>
|
|
55
|
+
<label class="sf"><span>密码</span><input type="password" id="db-password" placeholder="••••••" autocomplete="new-password"></label>
|
|
56
|
+
<label class="sf"><span>数据库名</span><input type="text" id="db-database" placeholder="ework" autocomplete="off"></label>
|
|
57
|
+
<label class="sf"><span>表前缀(可选)</span><input type="text" id="db-prefix" placeholder="留空 = 无前缀" autocomplete="off"></label>
|
|
58
|
+
<div class="db-controls">
|
|
59
|
+
<button type="button" id="db-test">① 测试连接</button>
|
|
60
|
+
<button type="button" id="db-migrate" class="secondary">② 迁移数据</button>
|
|
61
|
+
<button type="button" id="db-daemon" class="secondary">③ 配置 daemon</button>
|
|
62
|
+
<button type="button" id="db-enable" class="secondary">④ 启用并重启</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div id="db-result" class="db-result"></div>
|
|
65
|
+
${isMysql ? `<hr style="border:0;border-top:1px solid var(--border);margin:1rem 0"><div class="db-controls"><button type="button" id="db-revert" class="secondary">⚠ 切回 SQLite(安全网)</button></div><div id="db-revert-result" class="db-result"></div>` : ""}
|
|
66
|
+
<script src="/static/db-wizard.js"></script>
|
|
67
|
+
</section>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
41
70
|
export function buildSettingsPage(cfg: Config, saved: boolean, viewer: UserRow, models: CachedModel[]): { html: string } {
|
|
42
71
|
const groups = SETTINGS_GROUPS.map(
|
|
43
72
|
(g) =>
|
|
@@ -70,6 +99,16 @@ h1{font-size:18px;margin:0 0 .4rem}
|
|
|
70
99
|
button{padding:.5rem 1.2rem;border:0;border-radius:6px;background:var(--accent);color:#fff;font-size:13px;cursor:pointer}
|
|
71
100
|
button.secondary{background:transparent;color:var(--text-muted);border:1px solid var(--border)}
|
|
72
101
|
.a-back{color:var(--text-muted);font-size:13px}
|
|
102
|
+
.db-badge{font-size:13px;color:var(--text-muted);margin:0 0 .5rem}
|
|
103
|
+
.db-badge strong{color:var(--text)}
|
|
104
|
+
.db-warn{background:rgba(255,193,7,.12);color:#d4a942;padding:.5rem .7rem;border-radius:6px;font-size:12px;margin:0 0 .7rem;line-height:1.5}
|
|
105
|
+
.db-warn code{background:var(--bg);padding:.1rem .3rem;border-radius:3px;font-size:11px}
|
|
106
|
+
.db-controls{display:flex;gap:.6rem;flex-wrap:wrap;margin-top:.6rem}
|
|
107
|
+
.db-result{margin-top:.6rem;padding:.5rem .7rem;border-radius:6px;font-size:13px;white-space:pre-wrap;min-height:1.2rem;line-height:1.5}
|
|
108
|
+
.db-result:empty{display:none}
|
|
109
|
+
.db-result.db-loading{background:var(--bg);color:var(--text-muted)}
|
|
110
|
+
.db-result.db-ok{background:rgba(40,167,69,.15);color:#5eb88a}
|
|
111
|
+
.db-result.db-err{background:rgba(220,53,69,.15);color:#e87c7c}
|
|
73
112
|
</style></head><body>
|
|
74
113
|
<header class="nav"><a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.8"> · 设置</span></header>
|
|
75
114
|
<main class="wrap">
|
|
@@ -81,6 +120,7 @@ ${banner}
|
|
|
81
120
|
</form>
|
|
82
121
|
${modelRefreshForm}
|
|
83
122
|
${ttsLink}
|
|
123
|
+
${buildDbSection(viewer)}
|
|
84
124
|
</main></body></html>`;
|
|
85
125
|
return { html };
|
|
86
126
|
}
|