ework-web 0.2.1 → 0.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/package.json +4 -2
- package/src/auth.ts +8 -8
- package/src/config.ts +2 -2
- package/src/db-admin.ts +566 -0
- package/src/db.ts +286 -49
- package/src/giteaApi.ts +53 -53
- package/src/index.ts +261 -102
- package/src/reactions.ts +2 -2
- package/src/schema-mysql.sql +178 -0
- package/src/schema.sql +40 -40
- package/src/store.ts +339 -379
- package/src/views/home.ts +10 -9
- package/src/views/issueList.ts +4 -4
- package/src/views/issueThread.ts +21 -21
- package/src/views/issues.ts +3 -3
- package/src/views/projectMembers.ts +8 -9
- package/src/views/projectUpstreams.ts +3 -3
- package/src/views/settings.ts +82 -0
- package/src/webhooks.ts +63 -73
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
|
-
import { setConfig } from "./db";
|
|
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";
|
|
@@ -89,10 +93,11 @@ import { handleGiteaApi } from "./giteaApi";
|
|
|
89
93
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
90
94
|
const STATIC_DIR = join(__dirname, "static");
|
|
91
95
|
|
|
92
|
-
|
|
96
|
+
await initDB();
|
|
97
|
+
const cfg: Config = await loadConfig();
|
|
93
98
|
const opencode = new OpencodeClient(cfg);
|
|
94
99
|
|
|
95
|
-
function autoWireDaemon(projectId: number, origin: string): void {
|
|
100
|
+
async function autoWireDaemon(projectId: number, origin: string): Promise<void> {
|
|
96
101
|
if (!cfg.autowireActive) {
|
|
97
102
|
log.info("autoWireDaemon: skipped (WORK_AUTOWIRE_ACTIVE=false)", { projectId });
|
|
98
103
|
return;
|
|
@@ -101,7 +106,7 @@ function autoWireDaemon(projectId: number, origin: string): void {
|
|
|
101
106
|
const hookUrl = cfg.daemonWebhookUrl.trim();
|
|
102
107
|
if (botLogin) {
|
|
103
108
|
try {
|
|
104
|
-
addProjectMember(projectId, botLogin, "writer");
|
|
109
|
+
await addProjectMember(projectId, botLogin, "writer");
|
|
105
110
|
} catch (e) {
|
|
106
111
|
log.warn("autoWireDaemon: addProjectMember failed", { botLogin, err: e as Error });
|
|
107
112
|
}
|
|
@@ -109,15 +114,15 @@ function autoWireDaemon(projectId: number, origin: string): void {
|
|
|
109
114
|
if (hookUrl) {
|
|
110
115
|
try {
|
|
111
116
|
const target = hookUrl.replace(/\/$/, "") + "/webhook/gitea";
|
|
112
|
-
const exists = listWebhooks(projectId).some((w) => w.url === target);
|
|
117
|
+
const exists = (await listWebhooks(projectId)).some((w) => w.url === target);
|
|
113
118
|
if (!exists) {
|
|
114
|
-
createWebhook({
|
|
119
|
+
await createWebhook({
|
|
115
120
|
project_id: projectId,
|
|
116
121
|
url: target,
|
|
117
122
|
secret: cfg.daemonWebhookSecret,
|
|
118
123
|
events: ["issues", "issue_comment"],
|
|
119
124
|
});
|
|
120
|
-
emitPingEvent(projectId, origin);
|
|
125
|
+
void emitPingEvent(projectId, origin);
|
|
121
126
|
}
|
|
122
127
|
} catch (e) {
|
|
123
128
|
log.warn("autoWireDaemon: createWebhook failed", { hookUrl, err: e as Error });
|
|
@@ -171,6 +176,98 @@ function html(body: string, status = 200): Response {
|
|
|
171
176
|
});
|
|
172
177
|
}
|
|
173
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
|
+
|
|
174
271
|
const REPO_ISSUE_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)$/;
|
|
175
272
|
const REPO_LIST_RE = /^\/([^/]+)\/([^/]+)\/issues$/;
|
|
176
273
|
const REPO_NEW_RE = /^\/([^/]+)\/([^/]+)\/issues\/new$/;
|
|
@@ -367,7 +464,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
367
464
|
if (att) {
|
|
368
465
|
const [, uuid] = att;
|
|
369
466
|
if (!uuid) return new Response(null, { status: 400, headers: SEC_HEADERS });
|
|
370
|
-
const row = getAttachment(uuid);
|
|
467
|
+
const row = await getAttachment(uuid);
|
|
371
468
|
if (!row) return new Response(null, { status: 404, headers: SEC_HEADERS });
|
|
372
469
|
const stream = readAttachmentStream(uuid);
|
|
373
470
|
if (!stream) return new Response(null, { status: 404, headers: SEC_HEADERS });
|
|
@@ -398,10 +495,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
398
495
|
const form = await req.formData().catch(() => new FormData());
|
|
399
496
|
const f: Record<string, string | undefined> = {};
|
|
400
497
|
for (const [k, v] of form.entries()) f[k] = typeof v === "string" ? v : undefined;
|
|
401
|
-
const r = handleCreateProject(f);
|
|
498
|
+
const r = await handleCreateProject(f);
|
|
402
499
|
if (r.projectId) {
|
|
403
|
-
ensureProjectBootstrapAdmin(r.projectId, ctx.user!.login);
|
|
404
|
-
autoWireDaemon(r.projectId, url.origin);
|
|
500
|
+
await ensureProjectBootstrapAdmin(r.projectId, ctx.user!.login);
|
|
501
|
+
await autoWireDaemon(r.projectId, url.origin);
|
|
405
502
|
const q = new URLSearchParams({ created: "1" });
|
|
406
503
|
return Response.redirect(`${url.origin}${r.location}?${q}`, 303);
|
|
407
504
|
}
|
|
@@ -410,14 +507,14 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
410
507
|
}
|
|
411
508
|
const flashErr = url.searchParams.get("err");
|
|
412
509
|
const flash = flashErr ? { kind: "err" as const, msg: flashErr } : null;
|
|
413
|
-
return html(buildHome(ctx.user, flash));
|
|
510
|
+
return html(await buildHome(ctx.user, flash));
|
|
414
511
|
}
|
|
415
512
|
|
|
416
513
|
if (url.pathname === "/issues") {
|
|
417
514
|
const state = parseState(url.searchParams.get("state"));
|
|
418
515
|
const q = url.searchParams.get("q")?.trim() ?? "";
|
|
419
516
|
try {
|
|
420
|
-
return html(buildIssuesFeed(state, q));
|
|
517
|
+
return html(await buildIssuesFeed(state, q));
|
|
421
518
|
} catch (e) {
|
|
422
519
|
return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
|
|
423
520
|
}
|
|
@@ -604,9 +701,71 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
604
701
|
return new Response(stream, { status: 200, headers });
|
|
605
702
|
}
|
|
606
703
|
|
|
704
|
+
if (req.method === "POST" && url.pathname === "/api/db/test") {
|
|
705
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
706
|
+
if (!rateLimit(`db-test:${ip}`, 5, 5 / 60)) return json({ error: "rate limited" }, 429);
|
|
707
|
+
const payload = await req.json().catch(() => ({}));
|
|
708
|
+
const parsed = parseDbTargetOpts(payload);
|
|
709
|
+
if ("error" in parsed) return json(parsed, 400);
|
|
710
|
+
const result = await testMysqlConnection(parsed);
|
|
711
|
+
return json(result, result.ok ? 200 : 422);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (req.method === "POST" && url.pathname === "/api/db/migrate") {
|
|
715
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
716
|
+
if (!rateLimit(`db-migrate:${ip}`, 2, 2 / 3600)) return json({ error: "rate limited" }, 429);
|
|
717
|
+
const payload = await req.json().catch(() => ({}));
|
|
718
|
+
const parsed = parseDbTargetOpts(payload);
|
|
719
|
+
if ("error" in parsed) return json(parsed, 400);
|
|
720
|
+
const result = await migrateSqliteToMysql(parsed);
|
|
721
|
+
return json(result, result.ok ? 200 : 422);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (req.method === "POST" && url.pathname === "/api/db/enable") {
|
|
725
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
726
|
+
if (!rateLimit(`db-enable:${ip}`, 3, 3 / 3600)) return json({ error: "rate limited" }, 429);
|
|
727
|
+
const payload = await req.json().catch(() => ({}));
|
|
728
|
+
const parsed = parseDbTargetOpts(payload);
|
|
729
|
+
if ("error" in parsed) return json(parsed, 400);
|
|
730
|
+
const envResult = await writeMysqlEnv(join(process.cwd(), ".env"), parsed);
|
|
731
|
+
scheduleRestart();
|
|
732
|
+
return json({ ok: true, ...envResult, restarting: true });
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (req.method === "POST" && url.pathname === "/api/db/daemon-config") {
|
|
736
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
737
|
+
if (!rateLimit(`db-daemon:${ip}`, 3, 3 / 3600)) return json({ error: "rate limited" }, 429);
|
|
738
|
+
const rawPayload = await req.json().catch(() => ({}));
|
|
739
|
+
const parsed = parseDbTargetOpts(rawPayload);
|
|
740
|
+
if ("error" in parsed) return json(parsed, 400);
|
|
741
|
+
const daemonOpts: MysqlTargetOpts = { ...parsed, prefix: daemonPrefix(parsed.prefix ?? "") };
|
|
742
|
+
const envPath = daemonEnvPath();
|
|
743
|
+
if (!envPath) {
|
|
744
|
+
return json({ ok: false, configured: false, manual: daemonManualInstructions(daemonOpts) });
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
const written = await writeMysqlEnv(envPath, daemonOpts);
|
|
748
|
+
spawnDaemonRestart();
|
|
749
|
+
return json({ ok: true, configured: true, envPath, written: written.written });
|
|
750
|
+
} catch (e) {
|
|
751
|
+
return json({ ok: false, configured: false, error: errMsg(e), manual: daemonManualInstructions(daemonOpts) });
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (req.method === "POST" && url.pathname === "/api/db/revert") {
|
|
756
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
757
|
+
if (!rateLimit(`db-revert:${ip}`, 2, 2 / 3600)) return json({ error: "rate limited" }, 429);
|
|
758
|
+
const targetPath = join(process.cwd(), "ework.sqlite");
|
|
759
|
+
const result = await migrateMysqlToSqlite(targetPath);
|
|
760
|
+
if (!result.ok) return json(result, 422);
|
|
761
|
+
await writeSqliteEnv(join(process.cwd(), ".env"), targetPath);
|
|
762
|
+
scheduleRestart();
|
|
763
|
+
return json({ ...result, targetPath, restarting: true });
|
|
764
|
+
}
|
|
765
|
+
|
|
607
766
|
if (url.pathname === "/settings") {
|
|
608
767
|
if (req.method === "GET") {
|
|
609
|
-
return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, listCachedModels()).html);
|
|
768
|
+
return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, await listCachedModels()).html);
|
|
610
769
|
}
|
|
611
770
|
if (req.method === "POST") {
|
|
612
771
|
if (!rateLimit(`settings:${ip}`, 10, 10 / 60)) return html(errorPage("太快了", "请稍后再试"), 429);
|
|
@@ -616,10 +775,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
616
775
|
const v = form.get(String(key));
|
|
617
776
|
if (typeof v !== "string") continue;
|
|
618
777
|
if (parseOverride(key, v) === null) { bad = String(key); break; }
|
|
619
|
-
setConfig(String(key), v);
|
|
778
|
+
await setConfig(String(key), v);
|
|
620
779
|
}
|
|
621
780
|
if (bad) return html(errorPage(`无效的字段: ${bad}`, "请检查输入后重试"), 400);
|
|
622
|
-
Object.assign(cfg, loadConfig());
|
|
781
|
+
Object.assign(cfg, await loadConfig());
|
|
623
782
|
const rh = new Headers(SEC_HEADERS);
|
|
624
783
|
rh.set("location", "/settings?saved=1");
|
|
625
784
|
return new Response(null, { status: 303, headers: rh });
|
|
@@ -629,7 +788,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
629
788
|
if (url.pathname === "/settings/models/refresh" && req.method === "POST") {
|
|
630
789
|
if (!ctx.user || ctx.user.is_admin !== 1) return html(errorPage("403", "需要管理员"), 403);
|
|
631
790
|
const ids = await opencode.listModels();
|
|
632
|
-
replaceCachedModels(ids);
|
|
791
|
+
await replaceCachedModels(ids);
|
|
633
792
|
// M-1: pin a default model so opencode never falls back to env vars. The
|
|
634
793
|
// previous empty default meant the daemon omitted --model and opencode
|
|
635
794
|
// picked the model from leaked env (e.g. OPENCODE_MODEL / a provider
|
|
@@ -638,8 +797,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
638
797
|
if (!cfg.defaultModel) {
|
|
639
798
|
const picked = ids.find((id) => typeof id === "string" && id.length > 0);
|
|
640
799
|
if (picked) {
|
|
641
|
-
setConfig("defaultModel", picked);
|
|
642
|
-
Object.assign(cfg, loadConfig());
|
|
800
|
+
await setConfig("defaultModel", picked);
|
|
801
|
+
Object.assign(cfg, await loadConfig());
|
|
643
802
|
}
|
|
644
803
|
}
|
|
645
804
|
const rh = new Headers(SEC_HEADERS);
|
|
@@ -684,7 +843,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
684
843
|
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
685
844
|
const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
|
|
686
845
|
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
|
|
687
|
-
return html(buildTokensPage(me, listPatsForUser(me.login), flash));
|
|
846
|
+
return html(buildTokensPage(me, await listPatsForUser(me.login), flash));
|
|
688
847
|
}
|
|
689
848
|
|
|
690
849
|
if (url.pathname === "/me/tokens/create" && req.method === "POST") {
|
|
@@ -725,7 +884,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
725
884
|
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
726
885
|
const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
|
|
727
886
|
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
|
|
728
|
-
return html(buildAdminUsersPage(ctx.user!, listUsers(), flash));
|
|
887
|
+
return html(buildAdminUsersPage(ctx.user!, await listUsers(), flash));
|
|
729
888
|
}
|
|
730
889
|
}
|
|
731
890
|
|
|
@@ -733,7 +892,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
733
892
|
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
734
893
|
const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
|
|
735
894
|
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
|
|
736
|
-
return html(buildAdminTokensPage(ctx.user!, listAllPatsWithUsers(), flash));
|
|
895
|
+
return html(buildAdminTokensPage(ctx.user!, await listAllPatsWithUsers(), flash));
|
|
737
896
|
}
|
|
738
897
|
|
|
739
898
|
const adminPatRevoke = url.pathname.match(/^\/admin\/tokens\/(\d+)\/revoke$/);
|
|
@@ -769,8 +928,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
769
928
|
if (!label) return ttsRedir("err", "label 必填");
|
|
770
929
|
if (cfg.ttsBackends.some((b) => b.id === id)) return ttsRedir("err", `ID '${id}' 已存在`);
|
|
771
930
|
const list = [...cfg.ttsBackends, { id, label, url: beUrl, voice }];
|
|
772
|
-
setConfig("ttsBackends", JSON.stringify(list));
|
|
773
|
-
Object.assign(cfg, loadConfig());
|
|
931
|
+
await setConfig("ttsBackends", JSON.stringify(list));
|
|
932
|
+
Object.assign(cfg, await loadConfig());
|
|
774
933
|
return ttsRedir("ok", `已添加 ${id}`);
|
|
775
934
|
}
|
|
776
935
|
|
|
@@ -790,9 +949,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
790
949
|
if (idx < 0) return ttsRedir("err", `后端 '${oldId}' 不存在`);
|
|
791
950
|
if (newId !== oldId && list.some((b) => b.id === newId)) return ttsRedir("err", `ID '${newId}' 已被占用`);
|
|
792
951
|
list[idx] = { id: newId, label, url: beUrl, voice };
|
|
793
|
-
setConfig("ttsBackends", JSON.stringify(list));
|
|
794
|
-
if (cfg.ttsDefaultBackend === oldId && newId !== oldId) setConfig("ttsDefaultBackend", newId);
|
|
795
|
-
Object.assign(cfg, loadConfig());
|
|
952
|
+
await setConfig("ttsBackends", JSON.stringify(list));
|
|
953
|
+
if (cfg.ttsDefaultBackend === oldId && newId !== oldId) await setConfig("ttsDefaultBackend", newId);
|
|
954
|
+
Object.assign(cfg, await loadConfig());
|
|
796
955
|
return ttsRedir("ok", `已更新 ${newId}`);
|
|
797
956
|
}
|
|
798
957
|
|
|
@@ -805,8 +964,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
805
964
|
const idx = list.findIndex((b) => b.id === id);
|
|
806
965
|
if (idx < 0) return ttsRedir("err", `后端 '${id}' 不存在`);
|
|
807
966
|
list.splice(idx, 1);
|
|
808
|
-
setConfig("ttsBackends", JSON.stringify(list));
|
|
809
|
-
Object.assign(cfg, loadConfig());
|
|
967
|
+
await setConfig("ttsBackends", JSON.stringify(list));
|
|
968
|
+
Object.assign(cfg, await loadConfig());
|
|
810
969
|
return ttsRedir("ok", `已删除 ${id}`);
|
|
811
970
|
}
|
|
812
971
|
|
|
@@ -846,24 +1005,24 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
846
1005
|
if (targetLogin === ctx.user!.login) {
|
|
847
1006
|
return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能改自己的 admin 状态")}`, 303);
|
|
848
1007
|
}
|
|
849
|
-
const u = getUserByLogin(targetLogin);
|
|
1008
|
+
const u = await getUserByLogin(targetLogin);
|
|
850
1009
|
if (!u) throw new StoreError(404, "用户不存在");
|
|
851
|
-
if (u.is_admin === 1 && countAdmins() <= 1) {
|
|
1010
|
+
if (u.is_admin === 1 && (await countAdmins()) <= 1) {
|
|
852
1011
|
return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("系统至少要保留一个 admin")}`, 303);
|
|
853
1012
|
}
|
|
854
|
-
const updated = updateUser(targetLogin, { is_admin: u.is_admin !== 1 });
|
|
1013
|
+
const updated = await updateUser(targetLogin, { is_admin: u.is_admin !== 1 });
|
|
855
1014
|
return Response.redirect(`${url.origin}/admin/users?ok=1&ok_msg=${encodeURIComponent(`${updated.login} ${updated.is_admin ? "已设为 admin" : "已取消 admin"}`)}`, 303);
|
|
856
1015
|
}
|
|
857
1016
|
if (action === "toggle-active") {
|
|
858
1017
|
if (targetLogin === ctx.user!.login) {
|
|
859
1018
|
return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能禁用自己的账户")}`, 303);
|
|
860
1019
|
}
|
|
861
|
-
const u = getUserByLogin(targetLogin);
|
|
1020
|
+
const u = await getUserByLogin(targetLogin);
|
|
862
1021
|
if (!u) throw new StoreError(404, "用户不存在");
|
|
863
|
-
if (u.is_active === 1 && u.is_admin === 1 && countAdmins() <= 1) {
|
|
1022
|
+
if (u.is_active === 1 && u.is_admin === 1 && (await countAdmins()) <= 1) {
|
|
864
1023
|
return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能禁用最后一个 admin")}`, 303);
|
|
865
1024
|
}
|
|
866
|
-
const updated = updateUser(targetLogin, { is_active: u.is_active !== 1 });
|
|
1025
|
+
const updated = await updateUser(targetLogin, { is_active: u.is_active !== 1 });
|
|
867
1026
|
return Response.redirect(`${url.origin}/admin/users?ok=1&ok_msg=${encodeURIComponent(`${updated.login} ${updated.is_active ? "已启用" : "已禁用"}`)}`, 303);
|
|
868
1027
|
}
|
|
869
1028
|
return html(errorPage("bad action", ""), 400);
|
|
@@ -913,10 +1072,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
913
1072
|
if (cl > MAX_ATTACHMENT_BYTES) return json({ error: "file too large (max 20MB)" }, 413);
|
|
914
1073
|
const number = Number(numStr);
|
|
915
1074
|
try {
|
|
916
|
-
const project = getProject(owner, repo);
|
|
1075
|
+
const project = await getProject(owner, repo);
|
|
917
1076
|
if (!project) return json({ error: "project not found" }, 404);
|
|
918
|
-
if (!canWriteProject(project.id, ctx.user)) return json({ error: "forbidden: needs writer role on project" }, 403);
|
|
919
|
-
const issue = getIssueWithMeta(project.id, number);
|
|
1077
|
+
if (!(await canWriteProject(project.id, ctx.user))) return json({ error: "forbidden: needs writer role on project" }, 403);
|
|
1078
|
+
const issue = await getIssueWithMeta(project.id, number);
|
|
920
1079
|
if (!issue) return json({ error: "issue not found" }, 404);
|
|
921
1080
|
const form = await req.formData().catch(() => null);
|
|
922
1081
|
const file = form?.get("attachment");
|
|
@@ -927,7 +1086,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
927
1086
|
const contentType = file.type || sniffImageContentType(filename);
|
|
928
1087
|
const uuid = newAttachmentUUID();
|
|
929
1088
|
const blobPath = saveAttachmentBlob(uuid, bytes);
|
|
930
|
-
createAttachment({
|
|
1089
|
+
await createAttachment({
|
|
931
1090
|
uuid,
|
|
932
1091
|
issue_id: issue.id,
|
|
933
1092
|
filename,
|
|
@@ -958,14 +1117,14 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
958
1117
|
const wantsStateChange = payload.close === true || payload.reopen === true;
|
|
959
1118
|
if (!hasBody && !wantsStateChange) return json({ error: "body required" }, 400);
|
|
960
1119
|
if (body.length > 65536) return json({ error: "body too long" }, 413);
|
|
961
|
-
const project = getProject(owner, repo);
|
|
1120
|
+
const project = await getProject(owner, repo);
|
|
962
1121
|
if (!project) return json({ error: "project not found" }, 404);
|
|
963
|
-
if (!canWriteProject(project.id, ctx.user)) return json({ error: "forbidden: needs writer role on project" }, 403);
|
|
964
|
-
const issue = getIssueWithMeta(project.id, number);
|
|
1122
|
+
if (!(await canWriteProject(project.id, ctx.user))) return json({ error: "forbidden: needs writer role on project" }, 403);
|
|
1123
|
+
const issue = await getIssueWithMeta(project.id, number);
|
|
965
1124
|
if (!issue) return json({ error: "issue not found" }, 404);
|
|
966
1125
|
let view: CommentView | null = null;
|
|
967
1126
|
if (hasBody) {
|
|
968
|
-
const c = postComment(issue.id, body, ctx.user!.login);
|
|
1127
|
+
const c = await postComment(issue.id, body, ctx.user!.login);
|
|
969
1128
|
view = {
|
|
970
1129
|
id: c.id,
|
|
971
1130
|
tag: classifyActor(c.body, c.author_kind),
|
|
@@ -978,11 +1137,11 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
978
1137
|
let closed = false;
|
|
979
1138
|
let reopened = false;
|
|
980
1139
|
if (payload.close === true) {
|
|
981
|
-
setIssueState(issue.id, "closed");
|
|
1140
|
+
await setIssueState(issue.id, "closed");
|
|
982
1141
|
closed = true;
|
|
983
1142
|
void emitIssueEvent(project.id, issue.id, "closed", url.origin);
|
|
984
1143
|
} else if (payload.reopen === true) {
|
|
985
|
-
setIssueState(issue.id, "open");
|
|
1144
|
+
await setIssueState(issue.id, "open");
|
|
986
1145
|
reopened = true;
|
|
987
1146
|
void emitIssueEvent(project.id, issue.id, "reopened", url.origin);
|
|
988
1147
|
}
|
|
@@ -1004,20 +1163,20 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1004
1163
|
const title = String(form.get("title") ?? "").trim();
|
|
1005
1164
|
const body = String(form.get("body") ?? "");
|
|
1006
1165
|
if (!title) return html(errorPage("标题不能为空", "回到上一页填写标题后重试"), 400);
|
|
1007
|
-
let project = getProject(owner, repo);
|
|
1166
|
+
let project = await getProject(owner, repo);
|
|
1008
1167
|
let createdProject = false;
|
|
1009
1168
|
if (!project) {
|
|
1010
|
-
project = createProjectSafe(owner, repo);
|
|
1169
|
+
project = await createProjectSafe(owner, repo);
|
|
1011
1170
|
createdProject = true;
|
|
1012
1171
|
}
|
|
1013
|
-
if (!canWriteProject(project.id, ctx.user)) {
|
|
1172
|
+
if (!(await canWriteProject(project.id, ctx.user))) {
|
|
1014
1173
|
return html(errorPage("无权限", "需要该项目 writer 及以上角色才能创建 issue"), 403);
|
|
1015
1174
|
}
|
|
1016
1175
|
if (createdProject) {
|
|
1017
|
-
ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
|
|
1018
|
-
autoWireDaemon(project.id, url.origin);
|
|
1176
|
+
await ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
|
|
1177
|
+
await autoWireDaemon(project.id, url.origin);
|
|
1019
1178
|
}
|
|
1020
|
-
const issue = createIssue(project.id, title, body, ctx.user!.login);
|
|
1179
|
+
const issue = await createIssue(project.id, title, body, ctx.user!.login);
|
|
1021
1180
|
void emitIssueEvent(project.id, issue.id, "opened", url.origin);
|
|
1022
1181
|
return Response.redirect(
|
|
1023
1182
|
`${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issue.number}`,
|
|
@@ -1033,9 +1192,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1033
1192
|
const [, owner, repo] = whCreate;
|
|
1034
1193
|
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
|
|
1035
1194
|
try {
|
|
1036
|
-
const project = getProject(owner, repo);
|
|
1195
|
+
const project = await getProject(owner, repo);
|
|
1037
1196
|
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1038
|
-
if (!canAdminProject(project.id, ctx.user)) {
|
|
1197
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1039
1198
|
return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
|
|
1040
1199
|
}
|
|
1041
1200
|
const form = await req.formData().catch(() => new FormData());
|
|
@@ -1044,7 +1203,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1044
1203
|
const events = form.getAll("events") as string[];
|
|
1045
1204
|
const validEvents = (events.length > 0 ? events : ["issues", "issue_comment"])
|
|
1046
1205
|
.filter((e): e is WebhookEventName => e === "issues" || e === "issue_comment");
|
|
1047
|
-
const wh = createWebhook({
|
|
1206
|
+
const wh = await createWebhook({
|
|
1048
1207
|
project_id: project.id,
|
|
1049
1208
|
url: url_,
|
|
1050
1209
|
secret,
|
|
@@ -1063,10 +1222,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1063
1222
|
if (memberAdd) {
|
|
1064
1223
|
const [, owner, repo] = memberAdd;
|
|
1065
1224
|
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
|
|
1066
|
-
const project = getProject(owner, repo);
|
|
1225
|
+
const project = await getProject(owner, repo);
|
|
1067
1226
|
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1068
1227
|
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/members`;
|
|
1069
|
-
if (!canAdminProject(project.id, ctx.user)) {
|
|
1228
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1070
1229
|
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
1071
1230
|
}
|
|
1072
1231
|
const form = await req.formData().catch(() => new FormData());
|
|
@@ -1076,7 +1235,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1076
1235
|
return Response.redirect(`${back}?err=${encodeURIComponent("非法角色")}`, 303);
|
|
1077
1236
|
}
|
|
1078
1237
|
try {
|
|
1079
|
-
addProjectMember(project.id, login, role);
|
|
1238
|
+
await addProjectMember(project.id, login, role);
|
|
1080
1239
|
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已添加 ${login} 为 ${role}`)}`, 303);
|
|
1081
1240
|
} catch (e) {
|
|
1082
1241
|
return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303);
|
|
@@ -1087,10 +1246,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1087
1246
|
if (memberAction) {
|
|
1088
1247
|
const [, owner, repo, targetLogin, action] = memberAction;
|
|
1089
1248
|
if (!(owner && repo && targetLogin && action)) return html(errorPage("bad path", ""), 400);
|
|
1090
|
-
const project = getProject(owner, repo);
|
|
1249
|
+
const project = await getProject(owner, repo);
|
|
1091
1250
|
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1092
1251
|
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/members`;
|
|
1093
|
-
if (!canAdminProject(project.id, ctx.user)) {
|
|
1252
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1094
1253
|
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
1095
1254
|
}
|
|
1096
1255
|
try {
|
|
@@ -1101,23 +1260,23 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1101
1260
|
return Response.redirect(`${back}?err=${encodeURIComponent("非法角色")}`, 303);
|
|
1102
1261
|
}
|
|
1103
1262
|
// Guard last admin: don't let the only project admin demote themselves.
|
|
1104
|
-
const current = getProjectMembership(project.id, targetLogin);
|
|
1263
|
+
const current = await getProjectMembership(project.id, targetLogin);
|
|
1105
1264
|
if (
|
|
1106
1265
|
current?.role === "admin" &&
|
|
1107
1266
|
role !== "admin" &&
|
|
1108
|
-
countProjectAdmins(project.id) <= 1
|
|
1267
|
+
(await countProjectAdmins(project.id)) <= 1
|
|
1109
1268
|
) {
|
|
1110
1269
|
return Response.redirect(`${back}?err=${encodeURIComponent("最后一个 admin 不能降级")}`, 303);
|
|
1111
1270
|
}
|
|
1112
|
-
setProjectMemberRole(project.id, targetLogin, role);
|
|
1271
|
+
await setProjectMemberRole(project.id, targetLogin, role);
|
|
1113
1272
|
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`${targetLogin} → ${role}`)}`, 303);
|
|
1114
1273
|
}
|
|
1115
1274
|
if (action === "remove") {
|
|
1116
|
-
const current = getProjectMembership(project.id, targetLogin);
|
|
1117
|
-
if (current?.role === "admin" && countProjectAdmins(project.id) <= 1) {
|
|
1275
|
+
const current = await getProjectMembership(project.id, targetLogin);
|
|
1276
|
+
if (current?.role === "admin" && (await countProjectAdmins(project.id)) <= 1) {
|
|
1118
1277
|
return Response.redirect(`${back}?err=${encodeURIComponent("最后一个 admin 不能移除")}`, 303);
|
|
1119
1278
|
}
|
|
1120
|
-
removeProjectMember(project.id, targetLogin);
|
|
1279
|
+
await removeProjectMember(project.id, targetLogin);
|
|
1121
1280
|
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已移除 ${targetLogin}`)}`, 303);
|
|
1122
1281
|
}
|
|
1123
1282
|
return html(errorPage("bad action", ""), 400);
|
|
@@ -1131,20 +1290,20 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1131
1290
|
const [, idStr, action] = whAction;
|
|
1132
1291
|
const id = Number(idStr || "0");
|
|
1133
1292
|
if (!id) return html(errorPage("bad webhook id", ""), 400);
|
|
1134
|
-
const wh = getWebhook(id);
|
|
1293
|
+
const wh = await getWebhook(id);
|
|
1135
1294
|
if (!wh) return html(errorPage("webhook 不存在", ""), 404);
|
|
1136
|
-
const project = getProjectById(wh.project_id);
|
|
1295
|
+
const project = await getProjectById(wh.project_id);
|
|
1137
1296
|
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1138
|
-
if (!canAdminProject(project.id, ctx.user)) {
|
|
1297
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1139
1298
|
return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
|
|
1140
1299
|
}
|
|
1141
1300
|
const back = `${url.origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/webhooks`;
|
|
1142
1301
|
if (action === "delete") {
|
|
1143
|
-
deleteWebhook(id);
|
|
1302
|
+
await deleteWebhook(id);
|
|
1144
1303
|
} else if (action === "toggle") {
|
|
1145
1304
|
const form = await req.formData().catch(() => new FormData());
|
|
1146
1305
|
const wantActive = form.get("active") === "1";
|
|
1147
|
-
setWebhookActive(id, wantActive);
|
|
1306
|
+
await setWebhookActive(id, wantActive);
|
|
1148
1307
|
} else if (action === "test") {
|
|
1149
1308
|
void emitPingEvent(wh.project_id, url.origin);
|
|
1150
1309
|
}
|
|
@@ -1155,15 +1314,15 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1155
1314
|
if (upstreamSave) {
|
|
1156
1315
|
const [, owner, repo] = upstreamSave;
|
|
1157
1316
|
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
|
|
1158
|
-
const project = getProject(owner, repo);
|
|
1317
|
+
const project = await getProject(owner, repo);
|
|
1159
1318
|
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1160
1319
|
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/upstreams`;
|
|
1161
|
-
if (!canAdminProject(project.id, ctx.user)) {
|
|
1320
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1162
1321
|
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
1163
1322
|
}
|
|
1164
1323
|
const form = await req.formData().catch(() => new FormData());
|
|
1165
1324
|
const raw = String(form.get("urls") ?? "");
|
|
1166
|
-
const result = trySetUpstreamUrls(project.id, raw);
|
|
1325
|
+
const result = await trySetUpstreamUrls(project.id, raw);
|
|
1167
1326
|
if (result.ok) {
|
|
1168
1327
|
return Response.redirect(
|
|
1169
1328
|
`${back}?ok=1&ok_msg=${encodeURIComponent(`已保存 ${result.urls.length} 个上游 URL`)}`,
|
|
@@ -1177,16 +1336,16 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1177
1336
|
if (modelSave) {
|
|
1178
1337
|
const [, owner, repo] = modelSave;
|
|
1179
1338
|
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
|
|
1180
|
-
const project = getProject(owner, repo);
|
|
1339
|
+
const project = await getProject(owner, repo);
|
|
1181
1340
|
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1182
1341
|
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/model`;
|
|
1183
|
-
if (!canAdminProject(project.id, ctx.user)) {
|
|
1342
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1184
1343
|
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
1185
1344
|
}
|
|
1186
1345
|
const form = await req.formData().catch(() => new FormData());
|
|
1187
1346
|
const raw = String(form.get("model") ?? "").trim();
|
|
1188
1347
|
try {
|
|
1189
|
-
setProjectModel(project.id, raw);
|
|
1348
|
+
await setProjectModel(project.id, raw);
|
|
1190
1349
|
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent("模型已保存")}`, 303);
|
|
1191
1350
|
} catch (e) {
|
|
1192
1351
|
const msg = e instanceof StoreError ? e.message : (e instanceof Error ? e.message : "保存失败");
|
|
@@ -1205,7 +1364,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1205
1364
|
try {
|
|
1206
1365
|
if (kind === "page") {
|
|
1207
1366
|
const page = Math.max(1, Number(url.searchParams.get("page") ?? "1") || 1);
|
|
1208
|
-
const { issue, views, currentPage, hasOlder } = fetchIssuePage(owner, repo, number, page);
|
|
1367
|
+
const { issue, views, currentPage, hasOlder } = await fetchIssuePage(owner, repo, number, page);
|
|
1209
1368
|
const payload = {
|
|
1210
1369
|
owner,
|
|
1211
1370
|
repo,
|
|
@@ -1220,7 +1379,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1220
1379
|
return json(payload);
|
|
1221
1380
|
}
|
|
1222
1381
|
const since = url.searchParams.get("since") ?? new Date(0).toISOString();
|
|
1223
|
-
const views = fetchIssueSince(owner, repo, number, since);
|
|
1382
|
+
const views = await fetchIssueSince(owner, repo, number, since);
|
|
1224
1383
|
return json({ comments: views });
|
|
1225
1384
|
} catch (e) {
|
|
1226
1385
|
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
|
@@ -1240,7 +1399,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1240
1399
|
if (!(owner && repo && numStr)) return html(errorPage("404", "bad path"), 404);
|
|
1241
1400
|
const number = Number(numStr);
|
|
1242
1401
|
try {
|
|
1243
|
-
const { html: body } = buildIssueThread(cfg, owner, repo, number, ctx.user?.login);
|
|
1402
|
+
const { html: body } = await buildIssueThread(cfg, owner, repo, number, ctx.user?.login);
|
|
1244
1403
|
return html(body);
|
|
1245
1404
|
} catch (e) {
|
|
1246
1405
|
const status = e instanceof StoreError ? e.status : 500;
|
|
@@ -1255,7 +1414,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1255
1414
|
const state = parseState(url.searchParams.get("state"));
|
|
1256
1415
|
const q = url.searchParams.get("q")?.trim() ?? "";
|
|
1257
1416
|
try {
|
|
1258
|
-
return html(buildIssueList(owner, repo, state, cfg.writesEnabled, q));
|
|
1417
|
+
return html(await buildIssueList(owner, repo, state, cfg.writesEnabled, q));
|
|
1259
1418
|
} catch (e) {
|
|
1260
1419
|
return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
|
|
1261
1420
|
}
|
|
@@ -1265,13 +1424,13 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1265
1424
|
if (whPage) {
|
|
1266
1425
|
const [, owner, repo] = whPage;
|
|
1267
1426
|
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
1268
|
-
const project = getProject(owner, repo);
|
|
1427
|
+
const project = await getProject(owner, repo);
|
|
1269
1428
|
if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
|
|
1270
|
-
const isAdmin = canAdminProject(project.id, ctx.user);
|
|
1429
|
+
const isAdmin = await canAdminProject(project.id, ctx.user);
|
|
1271
1430
|
if (!isAdmin) return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
|
|
1272
|
-
const hooks = listWebhooks(project.id);
|
|
1431
|
+
const hooks = await listWebhooks(project.id);
|
|
1273
1432
|
const deliveriesByWebhook = new Map(
|
|
1274
|
-
hooks.map((h) => [h.id, listDeliveries(h.id, 10)] as const)
|
|
1433
|
+
await Promise.all(hooks.map(async (h) => [h.id, await listDeliveries(h.id, 10)] as const))
|
|
1275
1434
|
);
|
|
1276
1435
|
return html(
|
|
1277
1436
|
buildWebhooksPage({ project, webhooks: hooks, deliveriesByWebhook })
|
|
@@ -1282,26 +1441,26 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1282
1441
|
if (membersPage) {
|
|
1283
1442
|
const [, owner, repo] = membersPage;
|
|
1284
1443
|
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
1285
|
-
const project = getProject(owner, repo);
|
|
1444
|
+
const project = await getProject(owner, repo);
|
|
1286
1445
|
if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
|
|
1287
1446
|
// Lazy bootstrap: site-admin opening a pre-RBAC project auto-inserts as admin.
|
|
1288
|
-
if (ctx.user!.is_admin === 1) ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
|
|
1289
|
-
if (!canAdminProject(project.id, ctx.user)) {
|
|
1447
|
+
if (ctx.user!.is_admin === 1) await ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
|
|
1448
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1290
1449
|
return html(errorPage("无权限", "需要该项目 admin 角色才能管理成员"), 403);
|
|
1291
1450
|
}
|
|
1292
1451
|
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
1293
1452
|
const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
|
|
1294
1453
|
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
|
|
1295
|
-
return html(buildProjectMembersPage(ctx.user!, project, flash));
|
|
1454
|
+
return html(await buildProjectMembersPage(ctx.user!, project, flash));
|
|
1296
1455
|
}
|
|
1297
1456
|
|
|
1298
1457
|
const upstreamsPage = url.pathname.match(REPO_UPSTREAMS_RE);
|
|
1299
1458
|
if (upstreamsPage) {
|
|
1300
1459
|
const [, owner, repo] = upstreamsPage;
|
|
1301
1460
|
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
1302
|
-
const project = getProject(owner, repo);
|
|
1461
|
+
const project = await getProject(owner, repo);
|
|
1303
1462
|
if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
|
|
1304
|
-
if (!canAdminProject(project.id, ctx.user)) {
|
|
1463
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1305
1464
|
return html(errorPage("无权限", "需要该项目 admin 角色才能管理上游"), 403);
|
|
1306
1465
|
}
|
|
1307
1466
|
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
@@ -1314,15 +1473,15 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1314
1473
|
if (modelPage) {
|
|
1315
1474
|
const [, owner, repo] = modelPage;
|
|
1316
1475
|
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
1317
|
-
const project = getProject(owner, repo);
|
|
1476
|
+
const project = await getProject(owner, repo);
|
|
1318
1477
|
if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
|
|
1319
|
-
if (!canAdminProject(project.id, ctx.user)) {
|
|
1478
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1320
1479
|
return html(errorPage("无权限", "需要该项目 admin 角色才能管理模型"), 403);
|
|
1321
1480
|
}
|
|
1322
1481
|
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
1323
1482
|
const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
|
|
1324
1483
|
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
|
|
1325
|
-
return html(buildProjectModelPage(project, cfg.defaultModel, listCachedModels(), flash).html);
|
|
1484
|
+
return html(buildProjectModelPage(project, cfg.defaultModel, await listCachedModels(), flash).html);
|
|
1326
1485
|
}
|
|
1327
1486
|
|
|
1328
1487
|
const repoMatch = url.pathname.match(REPO_RE);
|
|
@@ -1342,13 +1501,13 @@ function parseState(s: string | null): "open" | "closed" | "all" {
|
|
|
1342
1501
|
return s === "closed" ? "closed" : s === "all" ? "all" : "open";
|
|
1343
1502
|
}
|
|
1344
1503
|
|
|
1345
|
-
function createProjectSafe(owner: string, name: string) {
|
|
1346
|
-
let project = getProject(owner, name);
|
|
1504
|
+
async function createProjectSafe(owner: string, name: string) {
|
|
1505
|
+
let project = await getProject(owner, name);
|
|
1347
1506
|
if (project) return project;
|
|
1348
1507
|
// Auto-create project on first issue POST. Owner/name were already validated by the
|
|
1349
1508
|
// URL regex shape; allow creation here so `/<owner>/<new-repo>/issues` (POST) bootstraps
|
|
1350
1509
|
// a project in one step. Use createIssue's tx for atomicity.
|
|
1351
|
-
project = createProject(owner, name, "");
|
|
1510
|
+
project = await createProject(owner, name, "");
|
|
1352
1511
|
return project;
|
|
1353
1512
|
}
|
|
1354
1513
|
|
|
@@ -1361,13 +1520,13 @@ function errMsg(e: unknown): string {
|
|
|
1361
1520
|
// migration path (legacy cookies resolve to this user).
|
|
1362
1521
|
// Also ensure the reserved system user exists (kind=system, used to attribute
|
|
1363
1522
|
// automated actions like future cron / import jobs; not login-able).
|
|
1364
|
-
(() => {
|
|
1365
|
-
const op = ensureBootstrapAdmin(cfg.operatorLogin);
|
|
1366
|
-
if (op.is_admin === 0 && countAdmins() === 0) {
|
|
1367
|
-
updateUser(op.login, { is_admin: true });
|
|
1523
|
+
await (async () => {
|
|
1524
|
+
const op = await ensureBootstrapAdmin(cfg.operatorLogin);
|
|
1525
|
+
if (op.is_admin === 0 && (await countAdmins()) === 0) {
|
|
1526
|
+
await updateUser(op.login, { is_admin: true });
|
|
1368
1527
|
log.info("bootstrap: operator promoted to admin", { login: op.login });
|
|
1369
1528
|
}
|
|
1370
|
-
const sys = ensureBootstrapSystem(cfg.systemLogin);
|
|
1529
|
+
const sys = await ensureBootstrapSystem(cfg.systemLogin);
|
|
1371
1530
|
void sys;
|
|
1372
1531
|
})();
|
|
1373
1532
|
|