ework-web 0.2.1 → 0.3.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/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import { fileURLToPath } from "url";
3
3
  import { readFileSync, appendFileSync } from "fs";
4
4
  import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./config";
5
5
  import type { Config } from "./config";
6
- import { setConfig } from "./db";
6
+ import { setConfig, initDB } from "./db";
7
7
  import { checkAuth, makeAuthCookieHeader, clearAuthCookieHeader, loginHTML, sanitizeNext, ensureBootstrapAdmin, ensureBootstrapSystem, isReservedSystemLogin } from "./auth";
8
8
  import { OpencodeClient, OpencodeError } from "./opencode";
9
9
  import { renderMarkdown } from "./render/markdown";
@@ -89,10 +89,11 @@ import { handleGiteaApi } from "./giteaApi";
89
89
  const __dirname = dirname(fileURLToPath(import.meta.url));
90
90
  const STATIC_DIR = join(__dirname, "static");
91
91
 
92
- const cfg: Config = loadConfig();
92
+ await initDB();
93
+ const cfg: Config = await loadConfig();
93
94
  const opencode = new OpencodeClient(cfg);
94
95
 
95
- function autoWireDaemon(projectId: number, origin: string): void {
96
+ async function autoWireDaemon(projectId: number, origin: string): Promise<void> {
96
97
  if (!cfg.autowireActive) {
97
98
  log.info("autoWireDaemon: skipped (WORK_AUTOWIRE_ACTIVE=false)", { projectId });
98
99
  return;
@@ -101,7 +102,7 @@ function autoWireDaemon(projectId: number, origin: string): void {
101
102
  const hookUrl = cfg.daemonWebhookUrl.trim();
102
103
  if (botLogin) {
103
104
  try {
104
- addProjectMember(projectId, botLogin, "writer");
105
+ await addProjectMember(projectId, botLogin, "writer");
105
106
  } catch (e) {
106
107
  log.warn("autoWireDaemon: addProjectMember failed", { botLogin, err: e as Error });
107
108
  }
@@ -109,15 +110,15 @@ function autoWireDaemon(projectId: number, origin: string): void {
109
110
  if (hookUrl) {
110
111
  try {
111
112
  const target = hookUrl.replace(/\/$/, "") + "/webhook/gitea";
112
- const exists = listWebhooks(projectId).some((w) => w.url === target);
113
+ const exists = (await listWebhooks(projectId)).some((w) => w.url === target);
113
114
  if (!exists) {
114
- createWebhook({
115
+ await createWebhook({
115
116
  project_id: projectId,
116
117
  url: target,
117
118
  secret: cfg.daemonWebhookSecret,
118
119
  events: ["issues", "issue_comment"],
119
120
  });
120
- emitPingEvent(projectId, origin);
121
+ void emitPingEvent(projectId, origin);
121
122
  }
122
123
  } catch (e) {
123
124
  log.warn("autoWireDaemon: createWebhook failed", { hookUrl, err: e as Error });
@@ -367,7 +368,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
367
368
  if (att) {
368
369
  const [, uuid] = att;
369
370
  if (!uuid) return new Response(null, { status: 400, headers: SEC_HEADERS });
370
- const row = getAttachment(uuid);
371
+ const row = await getAttachment(uuid);
371
372
  if (!row) return new Response(null, { status: 404, headers: SEC_HEADERS });
372
373
  const stream = readAttachmentStream(uuid);
373
374
  if (!stream) return new Response(null, { status: 404, headers: SEC_HEADERS });
@@ -398,10 +399,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
398
399
  const form = await req.formData().catch(() => new FormData());
399
400
  const f: Record<string, string | undefined> = {};
400
401
  for (const [k, v] of form.entries()) f[k] = typeof v === "string" ? v : undefined;
401
- const r = handleCreateProject(f);
402
+ const r = await handleCreateProject(f);
402
403
  if (r.projectId) {
403
- ensureProjectBootstrapAdmin(r.projectId, ctx.user!.login);
404
- autoWireDaemon(r.projectId, url.origin);
404
+ await ensureProjectBootstrapAdmin(r.projectId, ctx.user!.login);
405
+ await autoWireDaemon(r.projectId, url.origin);
405
406
  const q = new URLSearchParams({ created: "1" });
406
407
  return Response.redirect(`${url.origin}${r.location}?${q}`, 303);
407
408
  }
@@ -410,14 +411,14 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
410
411
  }
411
412
  const flashErr = url.searchParams.get("err");
412
413
  const flash = flashErr ? { kind: "err" as const, msg: flashErr } : null;
413
- return html(buildHome(ctx.user, flash));
414
+ return html(await buildHome(ctx.user, flash));
414
415
  }
415
416
 
416
417
  if (url.pathname === "/issues") {
417
418
  const state = parseState(url.searchParams.get("state"));
418
419
  const q = url.searchParams.get("q")?.trim() ?? "";
419
420
  try {
420
- return html(buildIssuesFeed(state, q));
421
+ return html(await buildIssuesFeed(state, q));
421
422
  } catch (e) {
422
423
  return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
423
424
  }
@@ -606,7 +607,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
606
607
 
607
608
  if (url.pathname === "/settings") {
608
609
  if (req.method === "GET") {
609
- return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, listCachedModels()).html);
610
+ return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, await listCachedModels()).html);
610
611
  }
611
612
  if (req.method === "POST") {
612
613
  if (!rateLimit(`settings:${ip}`, 10, 10 / 60)) return html(errorPage("太快了", "请稍后再试"), 429);
@@ -616,10 +617,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
616
617
  const v = form.get(String(key));
617
618
  if (typeof v !== "string") continue;
618
619
  if (parseOverride(key, v) === null) { bad = String(key); break; }
619
- setConfig(String(key), v);
620
+ await setConfig(String(key), v);
620
621
  }
621
622
  if (bad) return html(errorPage(`无效的字段: ${bad}`, "请检查输入后重试"), 400);
622
- Object.assign(cfg, loadConfig());
623
+ Object.assign(cfg, await loadConfig());
623
624
  const rh = new Headers(SEC_HEADERS);
624
625
  rh.set("location", "/settings?saved=1");
625
626
  return new Response(null, { status: 303, headers: rh });
@@ -629,7 +630,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
629
630
  if (url.pathname === "/settings/models/refresh" && req.method === "POST") {
630
631
  if (!ctx.user || ctx.user.is_admin !== 1) return html(errorPage("403", "需要管理员"), 403);
631
632
  const ids = await opencode.listModels();
632
- replaceCachedModels(ids);
633
+ await replaceCachedModels(ids);
633
634
  // M-1: pin a default model so opencode never falls back to env vars. The
634
635
  // previous empty default meant the daemon omitted --model and opencode
635
636
  // picked the model from leaked env (e.g. OPENCODE_MODEL / a provider
@@ -638,8 +639,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
638
639
  if (!cfg.defaultModel) {
639
640
  const picked = ids.find((id) => typeof id === "string" && id.length > 0);
640
641
  if (picked) {
641
- setConfig("defaultModel", picked);
642
- Object.assign(cfg, loadConfig());
642
+ await setConfig("defaultModel", picked);
643
+ Object.assign(cfg, await loadConfig());
643
644
  }
644
645
  }
645
646
  const rh = new Headers(SEC_HEADERS);
@@ -684,7 +685,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
684
685
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
685
686
  const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
686
687
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
687
- return html(buildTokensPage(me, listPatsForUser(me.login), flash));
688
+ return html(buildTokensPage(me, await listPatsForUser(me.login), flash));
688
689
  }
689
690
 
690
691
  if (url.pathname === "/me/tokens/create" && req.method === "POST") {
@@ -725,7 +726,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
725
726
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
726
727
  const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
727
728
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
728
- return html(buildAdminUsersPage(ctx.user!, listUsers(), flash));
729
+ return html(buildAdminUsersPage(ctx.user!, await listUsers(), flash));
729
730
  }
730
731
  }
731
732
 
@@ -733,7 +734,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
733
734
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
734
735
  const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
735
736
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
736
- return html(buildAdminTokensPage(ctx.user!, listAllPatsWithUsers(), flash));
737
+ return html(buildAdminTokensPage(ctx.user!, await listAllPatsWithUsers(), flash));
737
738
  }
738
739
 
739
740
  const adminPatRevoke = url.pathname.match(/^\/admin\/tokens\/(\d+)\/revoke$/);
@@ -769,8 +770,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
769
770
  if (!label) return ttsRedir("err", "label 必填");
770
771
  if (cfg.ttsBackends.some((b) => b.id === id)) return ttsRedir("err", `ID '${id}' 已存在`);
771
772
  const list = [...cfg.ttsBackends, { id, label, url: beUrl, voice }];
772
- setConfig("ttsBackends", JSON.stringify(list));
773
- Object.assign(cfg, loadConfig());
773
+ await setConfig("ttsBackends", JSON.stringify(list));
774
+ Object.assign(cfg, await loadConfig());
774
775
  return ttsRedir("ok", `已添加 ${id}`);
775
776
  }
776
777
 
@@ -790,9 +791,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
790
791
  if (idx < 0) return ttsRedir("err", `后端 '${oldId}' 不存在`);
791
792
  if (newId !== oldId && list.some((b) => b.id === newId)) return ttsRedir("err", `ID '${newId}' 已被占用`);
792
793
  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());
794
+ await setConfig("ttsBackends", JSON.stringify(list));
795
+ if (cfg.ttsDefaultBackend === oldId && newId !== oldId) await setConfig("ttsDefaultBackend", newId);
796
+ Object.assign(cfg, await loadConfig());
796
797
  return ttsRedir("ok", `已更新 ${newId}`);
797
798
  }
798
799
 
@@ -805,8 +806,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
805
806
  const idx = list.findIndex((b) => b.id === id);
806
807
  if (idx < 0) return ttsRedir("err", `后端 '${id}' 不存在`);
807
808
  list.splice(idx, 1);
808
- setConfig("ttsBackends", JSON.stringify(list));
809
- Object.assign(cfg, loadConfig());
809
+ await setConfig("ttsBackends", JSON.stringify(list));
810
+ Object.assign(cfg, await loadConfig());
810
811
  return ttsRedir("ok", `已删除 ${id}`);
811
812
  }
812
813
 
@@ -846,24 +847,24 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
846
847
  if (targetLogin === ctx.user!.login) {
847
848
  return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能改自己的 admin 状态")}`, 303);
848
849
  }
849
- const u = getUserByLogin(targetLogin);
850
+ const u = await getUserByLogin(targetLogin);
850
851
  if (!u) throw new StoreError(404, "用户不存在");
851
- if (u.is_admin === 1 && countAdmins() <= 1) {
852
+ if (u.is_admin === 1 && (await countAdmins()) <= 1) {
852
853
  return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("系统至少要保留一个 admin")}`, 303);
853
854
  }
854
- const updated = updateUser(targetLogin, { is_admin: u.is_admin !== 1 });
855
+ const updated = await updateUser(targetLogin, { is_admin: u.is_admin !== 1 });
855
856
  return Response.redirect(`${url.origin}/admin/users?ok=1&ok_msg=${encodeURIComponent(`${updated.login} ${updated.is_admin ? "已设为 admin" : "已取消 admin"}`)}`, 303);
856
857
  }
857
858
  if (action === "toggle-active") {
858
859
  if (targetLogin === ctx.user!.login) {
859
860
  return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能禁用自己的账户")}`, 303);
860
861
  }
861
- const u = getUserByLogin(targetLogin);
862
+ const u = await getUserByLogin(targetLogin);
862
863
  if (!u) throw new StoreError(404, "用户不存在");
863
- if (u.is_active === 1 && u.is_admin === 1 && countAdmins() <= 1) {
864
+ if (u.is_active === 1 && u.is_admin === 1 && (await countAdmins()) <= 1) {
864
865
  return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能禁用最后一个 admin")}`, 303);
865
866
  }
866
- const updated = updateUser(targetLogin, { is_active: u.is_active !== 1 });
867
+ const updated = await updateUser(targetLogin, { is_active: u.is_active !== 1 });
867
868
  return Response.redirect(`${url.origin}/admin/users?ok=1&ok_msg=${encodeURIComponent(`${updated.login} ${updated.is_active ? "已启用" : "已禁用"}`)}`, 303);
868
869
  }
869
870
  return html(errorPage("bad action", ""), 400);
@@ -913,10 +914,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
913
914
  if (cl > MAX_ATTACHMENT_BYTES) return json({ error: "file too large (max 20MB)" }, 413);
914
915
  const number = Number(numStr);
915
916
  try {
916
- const project = getProject(owner, repo);
917
+ const project = await getProject(owner, repo);
917
918
  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);
919
+ if (!(await canWriteProject(project.id, ctx.user))) return json({ error: "forbidden: needs writer role on project" }, 403);
920
+ const issue = await getIssueWithMeta(project.id, number);
920
921
  if (!issue) return json({ error: "issue not found" }, 404);
921
922
  const form = await req.formData().catch(() => null);
922
923
  const file = form?.get("attachment");
@@ -927,7 +928,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
927
928
  const contentType = file.type || sniffImageContentType(filename);
928
929
  const uuid = newAttachmentUUID();
929
930
  const blobPath = saveAttachmentBlob(uuid, bytes);
930
- createAttachment({
931
+ await createAttachment({
931
932
  uuid,
932
933
  issue_id: issue.id,
933
934
  filename,
@@ -958,14 +959,14 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
958
959
  const wantsStateChange = payload.close === true || payload.reopen === true;
959
960
  if (!hasBody && !wantsStateChange) return json({ error: "body required" }, 400);
960
961
  if (body.length > 65536) return json({ error: "body too long" }, 413);
961
- const project = getProject(owner, repo);
962
+ const project = await getProject(owner, repo);
962
963
  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);
964
+ if (!(await canWriteProject(project.id, ctx.user))) return json({ error: "forbidden: needs writer role on project" }, 403);
965
+ const issue = await getIssueWithMeta(project.id, number);
965
966
  if (!issue) return json({ error: "issue not found" }, 404);
966
967
  let view: CommentView | null = null;
967
968
  if (hasBody) {
968
- const c = postComment(issue.id, body, ctx.user!.login);
969
+ const c = await postComment(issue.id, body, ctx.user!.login);
969
970
  view = {
970
971
  id: c.id,
971
972
  tag: classifyActor(c.body, c.author_kind),
@@ -978,11 +979,11 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
978
979
  let closed = false;
979
980
  let reopened = false;
980
981
  if (payload.close === true) {
981
- setIssueState(issue.id, "closed");
982
+ await setIssueState(issue.id, "closed");
982
983
  closed = true;
983
984
  void emitIssueEvent(project.id, issue.id, "closed", url.origin);
984
985
  } else if (payload.reopen === true) {
985
- setIssueState(issue.id, "open");
986
+ await setIssueState(issue.id, "open");
986
987
  reopened = true;
987
988
  void emitIssueEvent(project.id, issue.id, "reopened", url.origin);
988
989
  }
@@ -1004,20 +1005,20 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1004
1005
  const title = String(form.get("title") ?? "").trim();
1005
1006
  const body = String(form.get("body") ?? "");
1006
1007
  if (!title) return html(errorPage("标题不能为空", "回到上一页填写标题后重试"), 400);
1007
- let project = getProject(owner, repo);
1008
+ let project = await getProject(owner, repo);
1008
1009
  let createdProject = false;
1009
1010
  if (!project) {
1010
- project = createProjectSafe(owner, repo);
1011
+ project = await createProjectSafe(owner, repo);
1011
1012
  createdProject = true;
1012
1013
  }
1013
- if (!canWriteProject(project.id, ctx.user)) {
1014
+ if (!(await canWriteProject(project.id, ctx.user))) {
1014
1015
  return html(errorPage("无权限", "需要该项目 writer 及以上角色才能创建 issue"), 403);
1015
1016
  }
1016
1017
  if (createdProject) {
1017
- ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
1018
- autoWireDaemon(project.id, url.origin);
1018
+ await ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
1019
+ await autoWireDaemon(project.id, url.origin);
1019
1020
  }
1020
- const issue = createIssue(project.id, title, body, ctx.user!.login);
1021
+ const issue = await createIssue(project.id, title, body, ctx.user!.login);
1021
1022
  void emitIssueEvent(project.id, issue.id, "opened", url.origin);
1022
1023
  return Response.redirect(
1023
1024
  `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issue.number}`,
@@ -1033,9 +1034,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1033
1034
  const [, owner, repo] = whCreate;
1034
1035
  if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1035
1036
  try {
1036
- const project = getProject(owner, repo);
1037
+ const project = await getProject(owner, repo);
1037
1038
  if (!project) return html(errorPage("项目不存在", ""), 404);
1038
- if (!canAdminProject(project.id, ctx.user)) {
1039
+ if (!(await canAdminProject(project.id, ctx.user))) {
1039
1040
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
1040
1041
  }
1041
1042
  const form = await req.formData().catch(() => new FormData());
@@ -1044,7 +1045,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1044
1045
  const events = form.getAll("events") as string[];
1045
1046
  const validEvents = (events.length > 0 ? events : ["issues", "issue_comment"])
1046
1047
  .filter((e): e is WebhookEventName => e === "issues" || e === "issue_comment");
1047
- const wh = createWebhook({
1048
+ const wh = await createWebhook({
1048
1049
  project_id: project.id,
1049
1050
  url: url_,
1050
1051
  secret,
@@ -1063,10 +1064,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1063
1064
  if (memberAdd) {
1064
1065
  const [, owner, repo] = memberAdd;
1065
1066
  if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1066
- const project = getProject(owner, repo);
1067
+ const project = await getProject(owner, repo);
1067
1068
  if (!project) return html(errorPage("项目不存在", ""), 404);
1068
1069
  const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/members`;
1069
- if (!canAdminProject(project.id, ctx.user)) {
1070
+ if (!(await canAdminProject(project.id, ctx.user))) {
1070
1071
  return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1071
1072
  }
1072
1073
  const form = await req.formData().catch(() => new FormData());
@@ -1076,7 +1077,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1076
1077
  return Response.redirect(`${back}?err=${encodeURIComponent("非法角色")}`, 303);
1077
1078
  }
1078
1079
  try {
1079
- addProjectMember(project.id, login, role);
1080
+ await addProjectMember(project.id, login, role);
1080
1081
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已添加 ${login} 为 ${role}`)}`, 303);
1081
1082
  } catch (e) {
1082
1083
  return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303);
@@ -1087,10 +1088,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1087
1088
  if (memberAction) {
1088
1089
  const [, owner, repo, targetLogin, action] = memberAction;
1089
1090
  if (!(owner && repo && targetLogin && action)) return html(errorPage("bad path", ""), 400);
1090
- const project = getProject(owner, repo);
1091
+ const project = await getProject(owner, repo);
1091
1092
  if (!project) return html(errorPage("项目不存在", ""), 404);
1092
1093
  const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/members`;
1093
- if (!canAdminProject(project.id, ctx.user)) {
1094
+ if (!(await canAdminProject(project.id, ctx.user))) {
1094
1095
  return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1095
1096
  }
1096
1097
  try {
@@ -1101,23 +1102,23 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1101
1102
  return Response.redirect(`${back}?err=${encodeURIComponent("非法角色")}`, 303);
1102
1103
  }
1103
1104
  // Guard last admin: don't let the only project admin demote themselves.
1104
- const current = getProjectMembership(project.id, targetLogin);
1105
+ const current = await getProjectMembership(project.id, targetLogin);
1105
1106
  if (
1106
1107
  current?.role === "admin" &&
1107
1108
  role !== "admin" &&
1108
- countProjectAdmins(project.id) <= 1
1109
+ (await countProjectAdmins(project.id)) <= 1
1109
1110
  ) {
1110
1111
  return Response.redirect(`${back}?err=${encodeURIComponent("最后一个 admin 不能降级")}`, 303);
1111
1112
  }
1112
- setProjectMemberRole(project.id, targetLogin, role);
1113
+ await setProjectMemberRole(project.id, targetLogin, role);
1113
1114
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`${targetLogin} → ${role}`)}`, 303);
1114
1115
  }
1115
1116
  if (action === "remove") {
1116
- const current = getProjectMembership(project.id, targetLogin);
1117
- if (current?.role === "admin" && countProjectAdmins(project.id) <= 1) {
1117
+ const current = await getProjectMembership(project.id, targetLogin);
1118
+ if (current?.role === "admin" && (await countProjectAdmins(project.id)) <= 1) {
1118
1119
  return Response.redirect(`${back}?err=${encodeURIComponent("最后一个 admin 不能移除")}`, 303);
1119
1120
  }
1120
- removeProjectMember(project.id, targetLogin);
1121
+ await removeProjectMember(project.id, targetLogin);
1121
1122
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已移除 ${targetLogin}`)}`, 303);
1122
1123
  }
1123
1124
  return html(errorPage("bad action", ""), 400);
@@ -1131,20 +1132,20 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1131
1132
  const [, idStr, action] = whAction;
1132
1133
  const id = Number(idStr || "0");
1133
1134
  if (!id) return html(errorPage("bad webhook id", ""), 400);
1134
- const wh = getWebhook(id);
1135
+ const wh = await getWebhook(id);
1135
1136
  if (!wh) return html(errorPage("webhook 不存在", ""), 404);
1136
- const project = getProjectById(wh.project_id);
1137
+ const project = await getProjectById(wh.project_id);
1137
1138
  if (!project) return html(errorPage("项目不存在", ""), 404);
1138
- if (!canAdminProject(project.id, ctx.user)) {
1139
+ if (!(await canAdminProject(project.id, ctx.user))) {
1139
1140
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
1140
1141
  }
1141
1142
  const back = `${url.origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/webhooks`;
1142
1143
  if (action === "delete") {
1143
- deleteWebhook(id);
1144
+ await deleteWebhook(id);
1144
1145
  } else if (action === "toggle") {
1145
1146
  const form = await req.formData().catch(() => new FormData());
1146
1147
  const wantActive = form.get("active") === "1";
1147
- setWebhookActive(id, wantActive);
1148
+ await setWebhookActive(id, wantActive);
1148
1149
  } else if (action === "test") {
1149
1150
  void emitPingEvent(wh.project_id, url.origin);
1150
1151
  }
@@ -1155,15 +1156,15 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1155
1156
  if (upstreamSave) {
1156
1157
  const [, owner, repo] = upstreamSave;
1157
1158
  if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1158
- const project = getProject(owner, repo);
1159
+ const project = await getProject(owner, repo);
1159
1160
  if (!project) return html(errorPage("项目不存在", ""), 404);
1160
1161
  const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/upstreams`;
1161
- if (!canAdminProject(project.id, ctx.user)) {
1162
+ if (!(await canAdminProject(project.id, ctx.user))) {
1162
1163
  return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1163
1164
  }
1164
1165
  const form = await req.formData().catch(() => new FormData());
1165
1166
  const raw = String(form.get("urls") ?? "");
1166
- const result = trySetUpstreamUrls(project.id, raw);
1167
+ const result = await trySetUpstreamUrls(project.id, raw);
1167
1168
  if (result.ok) {
1168
1169
  return Response.redirect(
1169
1170
  `${back}?ok=1&ok_msg=${encodeURIComponent(`已保存 ${result.urls.length} 个上游 URL`)}`,
@@ -1177,16 +1178,16 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1177
1178
  if (modelSave) {
1178
1179
  const [, owner, repo] = modelSave;
1179
1180
  if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1180
- const project = getProject(owner, repo);
1181
+ const project = await getProject(owner, repo);
1181
1182
  if (!project) return html(errorPage("项目不存在", ""), 404);
1182
1183
  const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/model`;
1183
- if (!canAdminProject(project.id, ctx.user)) {
1184
+ if (!(await canAdminProject(project.id, ctx.user))) {
1184
1185
  return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1185
1186
  }
1186
1187
  const form = await req.formData().catch(() => new FormData());
1187
1188
  const raw = String(form.get("model") ?? "").trim();
1188
1189
  try {
1189
- setProjectModel(project.id, raw);
1190
+ await setProjectModel(project.id, raw);
1190
1191
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent("模型已保存")}`, 303);
1191
1192
  } catch (e) {
1192
1193
  const msg = e instanceof StoreError ? e.message : (e instanceof Error ? e.message : "保存失败");
@@ -1205,7 +1206,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1205
1206
  try {
1206
1207
  if (kind === "page") {
1207
1208
  const page = Math.max(1, Number(url.searchParams.get("page") ?? "1") || 1);
1208
- const { issue, views, currentPage, hasOlder } = fetchIssuePage(owner, repo, number, page);
1209
+ const { issue, views, currentPage, hasOlder } = await fetchIssuePage(owner, repo, number, page);
1209
1210
  const payload = {
1210
1211
  owner,
1211
1212
  repo,
@@ -1220,7 +1221,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1220
1221
  return json(payload);
1221
1222
  }
1222
1223
  const since = url.searchParams.get("since") ?? new Date(0).toISOString();
1223
- const views = fetchIssueSince(owner, repo, number, since);
1224
+ const views = await fetchIssueSince(owner, repo, number, since);
1224
1225
  return json({ comments: views });
1225
1226
  } catch (e) {
1226
1227
  return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
@@ -1240,7 +1241,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1240
1241
  if (!(owner && repo && numStr)) return html(errorPage("404", "bad path"), 404);
1241
1242
  const number = Number(numStr);
1242
1243
  try {
1243
- const { html: body } = buildIssueThread(cfg, owner, repo, number, ctx.user?.login);
1244
+ const { html: body } = await buildIssueThread(cfg, owner, repo, number, ctx.user?.login);
1244
1245
  return html(body);
1245
1246
  } catch (e) {
1246
1247
  const status = e instanceof StoreError ? e.status : 500;
@@ -1255,7 +1256,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1255
1256
  const state = parseState(url.searchParams.get("state"));
1256
1257
  const q = url.searchParams.get("q")?.trim() ?? "";
1257
1258
  try {
1258
- return html(buildIssueList(owner, repo, state, cfg.writesEnabled, q));
1259
+ return html(await buildIssueList(owner, repo, state, cfg.writesEnabled, q));
1259
1260
  } catch (e) {
1260
1261
  return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
1261
1262
  }
@@ -1265,13 +1266,13 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1265
1266
  if (whPage) {
1266
1267
  const [, owner, repo] = whPage;
1267
1268
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1268
- const project = getProject(owner, repo);
1269
+ const project = await getProject(owner, repo);
1269
1270
  if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
1270
- const isAdmin = canAdminProject(project.id, ctx.user);
1271
+ const isAdmin = await canAdminProject(project.id, ctx.user);
1271
1272
  if (!isAdmin) return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
1272
- const hooks = listWebhooks(project.id);
1273
+ const hooks = await listWebhooks(project.id);
1273
1274
  const deliveriesByWebhook = new Map(
1274
- hooks.map((h) => [h.id, listDeliveries(h.id, 10)] as const)
1275
+ await Promise.all(hooks.map(async (h) => [h.id, await listDeliveries(h.id, 10)] as const))
1275
1276
  );
1276
1277
  return html(
1277
1278
  buildWebhooksPage({ project, webhooks: hooks, deliveriesByWebhook })
@@ -1282,26 +1283,26 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1282
1283
  if (membersPage) {
1283
1284
  const [, owner, repo] = membersPage;
1284
1285
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1285
- const project = getProject(owner, repo);
1286
+ const project = await getProject(owner, repo);
1286
1287
  if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
1287
1288
  // 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)) {
1289
+ if (ctx.user!.is_admin === 1) await ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
1290
+ if (!(await canAdminProject(project.id, ctx.user))) {
1290
1291
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理成员"), 403);
1291
1292
  }
1292
1293
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
1293
1294
  const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
1294
1295
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
1295
- return html(buildProjectMembersPage(ctx.user!, project, flash));
1296
+ return html(await buildProjectMembersPage(ctx.user!, project, flash));
1296
1297
  }
1297
1298
 
1298
1299
  const upstreamsPage = url.pathname.match(REPO_UPSTREAMS_RE);
1299
1300
  if (upstreamsPage) {
1300
1301
  const [, owner, repo] = upstreamsPage;
1301
1302
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1302
- const project = getProject(owner, repo);
1303
+ const project = await getProject(owner, repo);
1303
1304
  if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
1304
- if (!canAdminProject(project.id, ctx.user)) {
1305
+ if (!(await canAdminProject(project.id, ctx.user))) {
1305
1306
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理上游"), 403);
1306
1307
  }
1307
1308
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
@@ -1314,15 +1315,15 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1314
1315
  if (modelPage) {
1315
1316
  const [, owner, repo] = modelPage;
1316
1317
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1317
- const project = getProject(owner, repo);
1318
+ const project = await getProject(owner, repo);
1318
1319
  if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
1319
- if (!canAdminProject(project.id, ctx.user)) {
1320
+ if (!(await canAdminProject(project.id, ctx.user))) {
1320
1321
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理模型"), 403);
1321
1322
  }
1322
1323
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
1323
1324
  const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
1324
1325
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
1325
- return html(buildProjectModelPage(project, cfg.defaultModel, listCachedModels(), flash).html);
1326
+ return html(buildProjectModelPage(project, cfg.defaultModel, await listCachedModels(), flash).html);
1326
1327
  }
1327
1328
 
1328
1329
  const repoMatch = url.pathname.match(REPO_RE);
@@ -1342,13 +1343,13 @@ function parseState(s: string | null): "open" | "closed" | "all" {
1342
1343
  return s === "closed" ? "closed" : s === "all" ? "all" : "open";
1343
1344
  }
1344
1345
 
1345
- function createProjectSafe(owner: string, name: string) {
1346
- let project = getProject(owner, name);
1346
+ async function createProjectSafe(owner: string, name: string) {
1347
+ let project = await getProject(owner, name);
1347
1348
  if (project) return project;
1348
1349
  // Auto-create project on first issue POST. Owner/name were already validated by the
1349
1350
  // URL regex shape; allow creation here so `/<owner>/<new-repo>/issues` (POST) bootstraps
1350
1351
  // a project in one step. Use createIssue's tx for atomicity.
1351
- project = createProject(owner, name, "");
1352
+ project = await createProject(owner, name, "");
1352
1353
  return project;
1353
1354
  }
1354
1355
 
@@ -1361,13 +1362,13 @@ function errMsg(e: unknown): string {
1361
1362
  // migration path (legacy cookies resolve to this user).
1362
1363
  // Also ensure the reserved system user exists (kind=system, used to attribute
1363
1364
  // 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 });
1365
+ await (async () => {
1366
+ const op = await ensureBootstrapAdmin(cfg.operatorLogin);
1367
+ if (op.is_admin === 0 && (await countAdmins()) === 0) {
1368
+ await updateUser(op.login, { is_admin: true });
1368
1369
  log.info("bootstrap: operator promoted to admin", { login: op.login });
1369
1370
  }
1370
- const sys = ensureBootstrapSystem(cfg.systemLogin);
1371
+ const sys = await ensureBootstrapSystem(cfg.systemLogin);
1371
1372
  void sys;
1372
1373
  })();
1373
1374
 
package/src/reactions.ts CHANGED
@@ -13,10 +13,10 @@ export const REACTION_EMOJI: Record<string, string> = {
13
13
  tada: "🎉",
14
14
  };
15
15
 
16
- export function hydrateReactions(views: CommentView[]): void {
16
+ export async function hydrateReactions(views: CommentView[]): Promise<void> {
17
17
  const ids = views.map((v) => v.id);
18
18
  if (ids.length === 0) return;
19
- const aggs = listReactionsFor(ids);
19
+ const aggs = await listReactionsFor(ids);
20
20
  const byComment = new Map<number, { e: string; n: number }[]>();
21
21
  for (const a of aggs) {
22
22
  const emoji = REACTION_EMOJI[a.content] ?? a.content;