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/package.json +4 -2
- package/src/auth.ts +8 -8
- package/src/config.ts +3 -3
- package/src/db.ts +286 -49
- package/src/giteaApi.ts +53 -53
- package/src/index.ts +112 -99
- 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/projectModel.ts +1 -1
- 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,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;
|