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/package.json +4 -2
- package/src/auth.ts +8 -8
- package/src/config.ts +2 -2
- package/src/db.ts +286 -49
- package/src/giteaApi.ts +53 -53
- package/src/index.ts +102 -101
- 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/webhooks.ts +63 -73
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
|
-
|
|
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;
|