ework-web 0.2.0 → 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,19 @@ 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);
634
+ // M-1: pin a default model so opencode never falls back to env vars. The
635
+ // previous empty default meant the daemon omitted --model and opencode
636
+ // picked the model from leaked env (e.g. OPENCODE_MODEL / a provider
637
+ // default) — silent and wrong. Pick the first available model when none
638
+ // is configured; the operator can change or clear it on /settings.
639
+ if (!cfg.defaultModel) {
640
+ const picked = ids.find((id) => typeof id === "string" && id.length > 0);
641
+ if (picked) {
642
+ await setConfig("defaultModel", picked);
643
+ Object.assign(cfg, await loadConfig());
644
+ }
645
+ }
633
646
  const rh = new Headers(SEC_HEADERS);
634
647
  rh.set("location", "/settings?saved=1");
635
648
  return new Response(null, { status: 303, headers: rh });
@@ -672,7 +685,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
672
685
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
673
686
  const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
674
687
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
675
- return html(buildTokensPage(me, listPatsForUser(me.login), flash));
688
+ return html(buildTokensPage(me, await listPatsForUser(me.login), flash));
676
689
  }
677
690
 
678
691
  if (url.pathname === "/me/tokens/create" && req.method === "POST") {
@@ -713,7 +726,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
713
726
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
714
727
  const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
715
728
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
716
- return html(buildAdminUsersPage(ctx.user!, listUsers(), flash));
729
+ return html(buildAdminUsersPage(ctx.user!, await listUsers(), flash));
717
730
  }
718
731
  }
719
732
 
@@ -721,7 +734,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
721
734
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
722
735
  const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
723
736
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
724
- return html(buildAdminTokensPage(ctx.user!, listAllPatsWithUsers(), flash));
737
+ return html(buildAdminTokensPage(ctx.user!, await listAllPatsWithUsers(), flash));
725
738
  }
726
739
 
727
740
  const adminPatRevoke = url.pathname.match(/^\/admin\/tokens\/(\d+)\/revoke$/);
@@ -757,8 +770,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
757
770
  if (!label) return ttsRedir("err", "label 必填");
758
771
  if (cfg.ttsBackends.some((b) => b.id === id)) return ttsRedir("err", `ID '${id}' 已存在`);
759
772
  const list = [...cfg.ttsBackends, { id, label, url: beUrl, voice }];
760
- setConfig("ttsBackends", JSON.stringify(list));
761
- Object.assign(cfg, loadConfig());
773
+ await setConfig("ttsBackends", JSON.stringify(list));
774
+ Object.assign(cfg, await loadConfig());
762
775
  return ttsRedir("ok", `已添加 ${id}`);
763
776
  }
764
777
 
@@ -778,9 +791,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
778
791
  if (idx < 0) return ttsRedir("err", `后端 '${oldId}' 不存在`);
779
792
  if (newId !== oldId && list.some((b) => b.id === newId)) return ttsRedir("err", `ID '${newId}' 已被占用`);
780
793
  list[idx] = { id: newId, label, url: beUrl, voice };
781
- setConfig("ttsBackends", JSON.stringify(list));
782
- if (cfg.ttsDefaultBackend === oldId && newId !== oldId) setConfig("ttsDefaultBackend", newId);
783
- 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());
784
797
  return ttsRedir("ok", `已更新 ${newId}`);
785
798
  }
786
799
 
@@ -793,8 +806,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
793
806
  const idx = list.findIndex((b) => b.id === id);
794
807
  if (idx < 0) return ttsRedir("err", `后端 '${id}' 不存在`);
795
808
  list.splice(idx, 1);
796
- setConfig("ttsBackends", JSON.stringify(list));
797
- Object.assign(cfg, loadConfig());
809
+ await setConfig("ttsBackends", JSON.stringify(list));
810
+ Object.assign(cfg, await loadConfig());
798
811
  return ttsRedir("ok", `已删除 ${id}`);
799
812
  }
800
813
 
@@ -834,24 +847,24 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
834
847
  if (targetLogin === ctx.user!.login) {
835
848
  return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能改自己的 admin 状态")}`, 303);
836
849
  }
837
- const u = getUserByLogin(targetLogin);
850
+ const u = await getUserByLogin(targetLogin);
838
851
  if (!u) throw new StoreError(404, "用户不存在");
839
- if (u.is_admin === 1 && countAdmins() <= 1) {
852
+ if (u.is_admin === 1 && (await countAdmins()) <= 1) {
840
853
  return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("系统至少要保留一个 admin")}`, 303);
841
854
  }
842
- const updated = updateUser(targetLogin, { is_admin: u.is_admin !== 1 });
855
+ const updated = await updateUser(targetLogin, { is_admin: u.is_admin !== 1 });
843
856
  return Response.redirect(`${url.origin}/admin/users?ok=1&ok_msg=${encodeURIComponent(`${updated.login} ${updated.is_admin ? "已设为 admin" : "已取消 admin"}`)}`, 303);
844
857
  }
845
858
  if (action === "toggle-active") {
846
859
  if (targetLogin === ctx.user!.login) {
847
860
  return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能禁用自己的账户")}`, 303);
848
861
  }
849
- const u = getUserByLogin(targetLogin);
862
+ const u = await getUserByLogin(targetLogin);
850
863
  if (!u) throw new StoreError(404, "用户不存在");
851
- if (u.is_active === 1 && u.is_admin === 1 && countAdmins() <= 1) {
864
+ if (u.is_active === 1 && u.is_admin === 1 && (await countAdmins()) <= 1) {
852
865
  return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能禁用最后一个 admin")}`, 303);
853
866
  }
854
- const updated = updateUser(targetLogin, { is_active: u.is_active !== 1 });
867
+ const updated = await updateUser(targetLogin, { is_active: u.is_active !== 1 });
855
868
  return Response.redirect(`${url.origin}/admin/users?ok=1&ok_msg=${encodeURIComponent(`${updated.login} ${updated.is_active ? "已启用" : "已禁用"}`)}`, 303);
856
869
  }
857
870
  return html(errorPage("bad action", ""), 400);
@@ -901,10 +914,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
901
914
  if (cl > MAX_ATTACHMENT_BYTES) return json({ error: "file too large (max 20MB)" }, 413);
902
915
  const number = Number(numStr);
903
916
  try {
904
- const project = getProject(owner, repo);
917
+ const project = await getProject(owner, repo);
905
918
  if (!project) return json({ error: "project not found" }, 404);
906
- if (!canWriteProject(project.id, ctx.user)) return json({ error: "forbidden: needs writer role on project" }, 403);
907
- 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);
908
921
  if (!issue) return json({ error: "issue not found" }, 404);
909
922
  const form = await req.formData().catch(() => null);
910
923
  const file = form?.get("attachment");
@@ -915,7 +928,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
915
928
  const contentType = file.type || sniffImageContentType(filename);
916
929
  const uuid = newAttachmentUUID();
917
930
  const blobPath = saveAttachmentBlob(uuid, bytes);
918
- createAttachment({
931
+ await createAttachment({
919
932
  uuid,
920
933
  issue_id: issue.id,
921
934
  filename,
@@ -946,14 +959,14 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
946
959
  const wantsStateChange = payload.close === true || payload.reopen === true;
947
960
  if (!hasBody && !wantsStateChange) return json({ error: "body required" }, 400);
948
961
  if (body.length > 65536) return json({ error: "body too long" }, 413);
949
- const project = getProject(owner, repo);
962
+ const project = await getProject(owner, repo);
950
963
  if (!project) return json({ error: "project not found" }, 404);
951
- if (!canWriteProject(project.id, ctx.user)) return json({ error: "forbidden: needs writer role on project" }, 403);
952
- 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);
953
966
  if (!issue) return json({ error: "issue not found" }, 404);
954
967
  let view: CommentView | null = null;
955
968
  if (hasBody) {
956
- const c = postComment(issue.id, body, ctx.user!.login);
969
+ const c = await postComment(issue.id, body, ctx.user!.login);
957
970
  view = {
958
971
  id: c.id,
959
972
  tag: classifyActor(c.body, c.author_kind),
@@ -966,11 +979,11 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
966
979
  let closed = false;
967
980
  let reopened = false;
968
981
  if (payload.close === true) {
969
- setIssueState(issue.id, "closed");
982
+ await setIssueState(issue.id, "closed");
970
983
  closed = true;
971
984
  void emitIssueEvent(project.id, issue.id, "closed", url.origin);
972
985
  } else if (payload.reopen === true) {
973
- setIssueState(issue.id, "open");
986
+ await setIssueState(issue.id, "open");
974
987
  reopened = true;
975
988
  void emitIssueEvent(project.id, issue.id, "reopened", url.origin);
976
989
  }
@@ -992,20 +1005,20 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
992
1005
  const title = String(form.get("title") ?? "").trim();
993
1006
  const body = String(form.get("body") ?? "");
994
1007
  if (!title) return html(errorPage("标题不能为空", "回到上一页填写标题后重试"), 400);
995
- let project = getProject(owner, repo);
1008
+ let project = await getProject(owner, repo);
996
1009
  let createdProject = false;
997
1010
  if (!project) {
998
- project = createProjectSafe(owner, repo);
1011
+ project = await createProjectSafe(owner, repo);
999
1012
  createdProject = true;
1000
1013
  }
1001
- if (!canWriteProject(project.id, ctx.user)) {
1014
+ if (!(await canWriteProject(project.id, ctx.user))) {
1002
1015
  return html(errorPage("无权限", "需要该项目 writer 及以上角色才能创建 issue"), 403);
1003
1016
  }
1004
1017
  if (createdProject) {
1005
- ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
1006
- autoWireDaemon(project.id, url.origin);
1018
+ await ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
1019
+ await autoWireDaemon(project.id, url.origin);
1007
1020
  }
1008
- const issue = createIssue(project.id, title, body, ctx.user!.login);
1021
+ const issue = await createIssue(project.id, title, body, ctx.user!.login);
1009
1022
  void emitIssueEvent(project.id, issue.id, "opened", url.origin);
1010
1023
  return Response.redirect(
1011
1024
  `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issue.number}`,
@@ -1021,9 +1034,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1021
1034
  const [, owner, repo] = whCreate;
1022
1035
  if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1023
1036
  try {
1024
- const project = getProject(owner, repo);
1037
+ const project = await getProject(owner, repo);
1025
1038
  if (!project) return html(errorPage("项目不存在", ""), 404);
1026
- if (!canAdminProject(project.id, ctx.user)) {
1039
+ if (!(await canAdminProject(project.id, ctx.user))) {
1027
1040
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
1028
1041
  }
1029
1042
  const form = await req.formData().catch(() => new FormData());
@@ -1032,7 +1045,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1032
1045
  const events = form.getAll("events") as string[];
1033
1046
  const validEvents = (events.length > 0 ? events : ["issues", "issue_comment"])
1034
1047
  .filter((e): e is WebhookEventName => e === "issues" || e === "issue_comment");
1035
- const wh = createWebhook({
1048
+ const wh = await createWebhook({
1036
1049
  project_id: project.id,
1037
1050
  url: url_,
1038
1051
  secret,
@@ -1051,10 +1064,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1051
1064
  if (memberAdd) {
1052
1065
  const [, owner, repo] = memberAdd;
1053
1066
  if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1054
- const project = getProject(owner, repo);
1067
+ const project = await getProject(owner, repo);
1055
1068
  if (!project) return html(errorPage("项目不存在", ""), 404);
1056
1069
  const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/members`;
1057
- if (!canAdminProject(project.id, ctx.user)) {
1070
+ if (!(await canAdminProject(project.id, ctx.user))) {
1058
1071
  return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1059
1072
  }
1060
1073
  const form = await req.formData().catch(() => new FormData());
@@ -1064,7 +1077,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1064
1077
  return Response.redirect(`${back}?err=${encodeURIComponent("非法角色")}`, 303);
1065
1078
  }
1066
1079
  try {
1067
- addProjectMember(project.id, login, role);
1080
+ await addProjectMember(project.id, login, role);
1068
1081
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已添加 ${login} 为 ${role}`)}`, 303);
1069
1082
  } catch (e) {
1070
1083
  return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303);
@@ -1075,10 +1088,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1075
1088
  if (memberAction) {
1076
1089
  const [, owner, repo, targetLogin, action] = memberAction;
1077
1090
  if (!(owner && repo && targetLogin && action)) return html(errorPage("bad path", ""), 400);
1078
- const project = getProject(owner, repo);
1091
+ const project = await getProject(owner, repo);
1079
1092
  if (!project) return html(errorPage("项目不存在", ""), 404);
1080
1093
  const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/members`;
1081
- if (!canAdminProject(project.id, ctx.user)) {
1094
+ if (!(await canAdminProject(project.id, ctx.user))) {
1082
1095
  return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1083
1096
  }
1084
1097
  try {
@@ -1089,23 +1102,23 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1089
1102
  return Response.redirect(`${back}?err=${encodeURIComponent("非法角色")}`, 303);
1090
1103
  }
1091
1104
  // Guard last admin: don't let the only project admin demote themselves.
1092
- const current = getProjectMembership(project.id, targetLogin);
1105
+ const current = await getProjectMembership(project.id, targetLogin);
1093
1106
  if (
1094
1107
  current?.role === "admin" &&
1095
1108
  role !== "admin" &&
1096
- countProjectAdmins(project.id) <= 1
1109
+ (await countProjectAdmins(project.id)) <= 1
1097
1110
  ) {
1098
1111
  return Response.redirect(`${back}?err=${encodeURIComponent("最后一个 admin 不能降级")}`, 303);
1099
1112
  }
1100
- setProjectMemberRole(project.id, targetLogin, role);
1113
+ await setProjectMemberRole(project.id, targetLogin, role);
1101
1114
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`${targetLogin} → ${role}`)}`, 303);
1102
1115
  }
1103
1116
  if (action === "remove") {
1104
- const current = getProjectMembership(project.id, targetLogin);
1105
- 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) {
1106
1119
  return Response.redirect(`${back}?err=${encodeURIComponent("最后一个 admin 不能移除")}`, 303);
1107
1120
  }
1108
- removeProjectMember(project.id, targetLogin);
1121
+ await removeProjectMember(project.id, targetLogin);
1109
1122
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已移除 ${targetLogin}`)}`, 303);
1110
1123
  }
1111
1124
  return html(errorPage("bad action", ""), 400);
@@ -1119,20 +1132,20 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1119
1132
  const [, idStr, action] = whAction;
1120
1133
  const id = Number(idStr || "0");
1121
1134
  if (!id) return html(errorPage("bad webhook id", ""), 400);
1122
- const wh = getWebhook(id);
1135
+ const wh = await getWebhook(id);
1123
1136
  if (!wh) return html(errorPage("webhook 不存在", ""), 404);
1124
- const project = getProjectById(wh.project_id);
1137
+ const project = await getProjectById(wh.project_id);
1125
1138
  if (!project) return html(errorPage("项目不存在", ""), 404);
1126
- if (!canAdminProject(project.id, ctx.user)) {
1139
+ if (!(await canAdminProject(project.id, ctx.user))) {
1127
1140
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
1128
1141
  }
1129
1142
  const back = `${url.origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/webhooks`;
1130
1143
  if (action === "delete") {
1131
- deleteWebhook(id);
1144
+ await deleteWebhook(id);
1132
1145
  } else if (action === "toggle") {
1133
1146
  const form = await req.formData().catch(() => new FormData());
1134
1147
  const wantActive = form.get("active") === "1";
1135
- setWebhookActive(id, wantActive);
1148
+ await setWebhookActive(id, wantActive);
1136
1149
  } else if (action === "test") {
1137
1150
  void emitPingEvent(wh.project_id, url.origin);
1138
1151
  }
@@ -1143,15 +1156,15 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1143
1156
  if (upstreamSave) {
1144
1157
  const [, owner, repo] = upstreamSave;
1145
1158
  if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1146
- const project = getProject(owner, repo);
1159
+ const project = await getProject(owner, repo);
1147
1160
  if (!project) return html(errorPage("项目不存在", ""), 404);
1148
1161
  const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/upstreams`;
1149
- if (!canAdminProject(project.id, ctx.user)) {
1162
+ if (!(await canAdminProject(project.id, ctx.user))) {
1150
1163
  return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1151
1164
  }
1152
1165
  const form = await req.formData().catch(() => new FormData());
1153
1166
  const raw = String(form.get("urls") ?? "");
1154
- const result = trySetUpstreamUrls(project.id, raw);
1167
+ const result = await trySetUpstreamUrls(project.id, raw);
1155
1168
  if (result.ok) {
1156
1169
  return Response.redirect(
1157
1170
  `${back}?ok=1&ok_msg=${encodeURIComponent(`已保存 ${result.urls.length} 个上游 URL`)}`,
@@ -1165,16 +1178,16 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1165
1178
  if (modelSave) {
1166
1179
  const [, owner, repo] = modelSave;
1167
1180
  if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1168
- const project = getProject(owner, repo);
1181
+ const project = await getProject(owner, repo);
1169
1182
  if (!project) return html(errorPage("项目不存在", ""), 404);
1170
1183
  const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/model`;
1171
- if (!canAdminProject(project.id, ctx.user)) {
1184
+ if (!(await canAdminProject(project.id, ctx.user))) {
1172
1185
  return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1173
1186
  }
1174
1187
  const form = await req.formData().catch(() => new FormData());
1175
1188
  const raw = String(form.get("model") ?? "").trim();
1176
1189
  try {
1177
- setProjectModel(project.id, raw);
1190
+ await setProjectModel(project.id, raw);
1178
1191
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent("模型已保存")}`, 303);
1179
1192
  } catch (e) {
1180
1193
  const msg = e instanceof StoreError ? e.message : (e instanceof Error ? e.message : "保存失败");
@@ -1193,7 +1206,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1193
1206
  try {
1194
1207
  if (kind === "page") {
1195
1208
  const page = Math.max(1, Number(url.searchParams.get("page") ?? "1") || 1);
1196
- const { issue, views, currentPage, hasOlder } = fetchIssuePage(owner, repo, number, page);
1209
+ const { issue, views, currentPage, hasOlder } = await fetchIssuePage(owner, repo, number, page);
1197
1210
  const payload = {
1198
1211
  owner,
1199
1212
  repo,
@@ -1208,7 +1221,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1208
1221
  return json(payload);
1209
1222
  }
1210
1223
  const since = url.searchParams.get("since") ?? new Date(0).toISOString();
1211
- const views = fetchIssueSince(owner, repo, number, since);
1224
+ const views = await fetchIssueSince(owner, repo, number, since);
1212
1225
  return json({ comments: views });
1213
1226
  } catch (e) {
1214
1227
  return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
@@ -1228,7 +1241,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1228
1241
  if (!(owner && repo && numStr)) return html(errorPage("404", "bad path"), 404);
1229
1242
  const number = Number(numStr);
1230
1243
  try {
1231
- const { html: body } = buildIssueThread(cfg, owner, repo, number, ctx.user?.login);
1244
+ const { html: body } = await buildIssueThread(cfg, owner, repo, number, ctx.user?.login);
1232
1245
  return html(body);
1233
1246
  } catch (e) {
1234
1247
  const status = e instanceof StoreError ? e.status : 500;
@@ -1243,7 +1256,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1243
1256
  const state = parseState(url.searchParams.get("state"));
1244
1257
  const q = url.searchParams.get("q")?.trim() ?? "";
1245
1258
  try {
1246
- return html(buildIssueList(owner, repo, state, cfg.writesEnabled, q));
1259
+ return html(await buildIssueList(owner, repo, state, cfg.writesEnabled, q));
1247
1260
  } catch (e) {
1248
1261
  return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
1249
1262
  }
@@ -1253,13 +1266,13 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1253
1266
  if (whPage) {
1254
1267
  const [, owner, repo] = whPage;
1255
1268
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1256
- const project = getProject(owner, repo);
1269
+ const project = await getProject(owner, repo);
1257
1270
  if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
1258
- const isAdmin = canAdminProject(project.id, ctx.user);
1271
+ const isAdmin = await canAdminProject(project.id, ctx.user);
1259
1272
  if (!isAdmin) return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
1260
- const hooks = listWebhooks(project.id);
1273
+ const hooks = await listWebhooks(project.id);
1261
1274
  const deliveriesByWebhook = new Map(
1262
- 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))
1263
1276
  );
1264
1277
  return html(
1265
1278
  buildWebhooksPage({ project, webhooks: hooks, deliveriesByWebhook })
@@ -1270,26 +1283,26 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1270
1283
  if (membersPage) {
1271
1284
  const [, owner, repo] = membersPage;
1272
1285
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1273
- const project = getProject(owner, repo);
1286
+ const project = await getProject(owner, repo);
1274
1287
  if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
1275
1288
  // Lazy bootstrap: site-admin opening a pre-RBAC project auto-inserts as admin.
1276
- if (ctx.user!.is_admin === 1) ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
1277
- 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))) {
1278
1291
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理成员"), 403);
1279
1292
  }
1280
1293
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
1281
1294
  const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
1282
1295
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
1283
- return html(buildProjectMembersPage(ctx.user!, project, flash));
1296
+ return html(await buildProjectMembersPage(ctx.user!, project, flash));
1284
1297
  }
1285
1298
 
1286
1299
  const upstreamsPage = url.pathname.match(REPO_UPSTREAMS_RE);
1287
1300
  if (upstreamsPage) {
1288
1301
  const [, owner, repo] = upstreamsPage;
1289
1302
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1290
- const project = getProject(owner, repo);
1303
+ const project = await getProject(owner, repo);
1291
1304
  if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
1292
- if (!canAdminProject(project.id, ctx.user)) {
1305
+ if (!(await canAdminProject(project.id, ctx.user))) {
1293
1306
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理上游"), 403);
1294
1307
  }
1295
1308
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
@@ -1302,15 +1315,15 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1302
1315
  if (modelPage) {
1303
1316
  const [, owner, repo] = modelPage;
1304
1317
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1305
- const project = getProject(owner, repo);
1318
+ const project = await getProject(owner, repo);
1306
1319
  if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
1307
- if (!canAdminProject(project.id, ctx.user)) {
1320
+ if (!(await canAdminProject(project.id, ctx.user))) {
1308
1321
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理模型"), 403);
1309
1322
  }
1310
1323
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
1311
1324
  const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
1312
1325
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
1313
- return html(buildProjectModelPage(project, cfg.defaultModel, listCachedModels(), flash).html);
1326
+ return html(buildProjectModelPage(project, cfg.defaultModel, await listCachedModels(), flash).html);
1314
1327
  }
1315
1328
 
1316
1329
  const repoMatch = url.pathname.match(REPO_RE);
@@ -1330,13 +1343,13 @@ function parseState(s: string | null): "open" | "closed" | "all" {
1330
1343
  return s === "closed" ? "closed" : s === "all" ? "all" : "open";
1331
1344
  }
1332
1345
 
1333
- function createProjectSafe(owner: string, name: string) {
1334
- let project = getProject(owner, name);
1346
+ async function createProjectSafe(owner: string, name: string) {
1347
+ let project = await getProject(owner, name);
1335
1348
  if (project) return project;
1336
1349
  // Auto-create project on first issue POST. Owner/name were already validated by the
1337
1350
  // URL regex shape; allow creation here so `/<owner>/<new-repo>/issues` (POST) bootstraps
1338
1351
  // a project in one step. Use createIssue's tx for atomicity.
1339
- project = createProject(owner, name, "");
1352
+ project = await createProject(owner, name, "");
1340
1353
  return project;
1341
1354
  }
1342
1355
 
@@ -1349,13 +1362,13 @@ function errMsg(e: unknown): string {
1349
1362
  // migration path (legacy cookies resolve to this user).
1350
1363
  // Also ensure the reserved system user exists (kind=system, used to attribute
1351
1364
  // automated actions like future cron / import jobs; not login-able).
1352
- (() => {
1353
- const op = ensureBootstrapAdmin(cfg.operatorLogin);
1354
- if (op.is_admin === 0 && countAdmins() === 0) {
1355
- 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 });
1356
1369
  log.info("bootstrap: operator promoted to admin", { login: op.login });
1357
1370
  }
1358
- const sys = ensureBootstrapSystem(cfg.systemLogin);
1371
+ const sys = await ensureBootstrapSystem(cfg.systemLogin);
1359
1372
  void sys;
1360
1373
  })();
1361
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;