ework-web 0.1.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/LICENSE +21 -0
- package/README.md +170 -0
- package/bin/ework-web.js +2 -0
- package/package.json +61 -0
- package/src/attachments.ts +54 -0
- package/src/auth.ts +218 -0
- package/src/build.ts +17 -0
- package/src/config.ts +178 -0
- package/src/db.ts +106 -0
- package/src/fileview.ts +617 -0
- package/src/giteaApi.ts +333 -0
- package/src/index.ts +1310 -0
- package/src/logger.ts +44 -0
- package/src/opencode.ts +340 -0
- package/src/ratelimit.ts +35 -0
- package/src/reactions.ts +31 -0
- package/src/render/components.ts +66 -0
- package/src/render/layout.ts +240 -0
- package/src/render/markdown.ts +106 -0
- package/src/schema.sql +178 -0
- package/src/static/app.js +571 -0
- package/src/static/favicon.svg +6 -0
- package/src/static/file.js +103 -0
- package/src/static/session.js +380 -0
- package/src/static/tts.js +101 -0
- package/src/store.ts +1020 -0
- package/src/translate.ts +166 -0
- package/src/views/adminTokens.ts +122 -0
- package/src/views/home.ts +109 -0
- package/src/views/issueList.ts +89 -0
- package/src/views/issueNew.ts +35 -0
- package/src/views/issueThread.ts +179 -0
- package/src/views/issues.ts +66 -0
- package/src/views/projectMembers.ts +146 -0
- package/src/views/projectUpstreams.ts +145 -0
- package/src/views/sessionLog.ts +709 -0
- package/src/views/settings.ts +67 -0
- package/src/views/tokens.ts +146 -0
- package/src/views/ttsBackends.ts +98 -0
- package/src/views/users.ts +163 -0
- package/src/views/webhooks.ts +166 -0
- package/src/webhooks.ts +634 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1310 @@
|
|
|
1
|
+
import { join, dirname } from "path";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { readFileSync, appendFileSync } from "fs";
|
|
4
|
+
import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./config";
|
|
5
|
+
import type { Config } from "./config";
|
|
6
|
+
import { setConfig } from "./db";
|
|
7
|
+
import { checkAuth, makeAuthCookieHeader, clearAuthCookieHeader, loginHTML, sanitizeNext, ensureBootstrapAdmin, ensureBootstrapSystem, isReservedSystemLogin } from "./auth";
|
|
8
|
+
import { OpencodeClient, OpencodeError } from "./opencode";
|
|
9
|
+
import { renderMarkdown } from "./render/markdown";
|
|
10
|
+
import { log, uptimeSeconds, version } from "./logger";
|
|
11
|
+
import { buildIssueThread, fetchIssuePage, fetchIssueSince, errorPage } from "./views/issueThread";
|
|
12
|
+
import { buildIssueList } from "./views/issueList";
|
|
13
|
+
import { buildHome, handleCreateProject } from "./views/home";
|
|
14
|
+
import { buildIssuesFeed } from "./views/issues";
|
|
15
|
+
import { buildIssueNew } from "./views/issueNew";
|
|
16
|
+
import { buildSettingsPage } from "./views/settings";
|
|
17
|
+
import { buildTtsBackendsPage } from "./views/ttsBackends";
|
|
18
|
+
import { buildMePage, buildAdminUsersPage } from "./views/users";
|
|
19
|
+
import { buildTokensPage, buildTokenCreatedPage } from "./views/tokens";
|
|
20
|
+
import { buildAdminTokensPage } from "./views/adminTokens";
|
|
21
|
+
import { buildSessionList, buildSessionView, renderNewMessages, renderBatchHTML } from "./views/sessionLog";
|
|
22
|
+
import { buildFileView, FileViewError, readFileSince, serveRawFile } from "./fileview";
|
|
23
|
+
import { translateText, translateTextStream, TranslateError } from "./translate";
|
|
24
|
+
import { rateLimit } from "./ratelimit";
|
|
25
|
+
import {
|
|
26
|
+
StoreError,
|
|
27
|
+
getProject,
|
|
28
|
+
getProjectById,
|
|
29
|
+
getIssueWithMeta,
|
|
30
|
+
createIssue,
|
|
31
|
+
createProject,
|
|
32
|
+
postComment,
|
|
33
|
+
setIssueState,
|
|
34
|
+
createAttachment,
|
|
35
|
+
getAttachment,
|
|
36
|
+
verifyUserPassword,
|
|
37
|
+
updateUser,
|
|
38
|
+
countAdmins,
|
|
39
|
+
createUser,
|
|
40
|
+
getUserByLogin,
|
|
41
|
+
setUserPassword,
|
|
42
|
+
listUsers,
|
|
43
|
+
createPat,
|
|
44
|
+
listPatsForUser,
|
|
45
|
+
revokePat,
|
|
46
|
+
revokePatAsAdmin,
|
|
47
|
+
listAllPatsWithUsers,
|
|
48
|
+
canWriteProject,
|
|
49
|
+
canAdminProject,
|
|
50
|
+
ensureProjectBootstrapAdmin,
|
|
51
|
+
addProjectMember,
|
|
52
|
+
setProjectMemberRole,
|
|
53
|
+
removeProjectMember,
|
|
54
|
+
countProjectAdmins,
|
|
55
|
+
getProjectMembership,
|
|
56
|
+
type ProjectRole,
|
|
57
|
+
type UserRow,
|
|
58
|
+
} from "./store";
|
|
59
|
+
import {
|
|
60
|
+
newAttachmentUUID,
|
|
61
|
+
saveAttachmentBlob,
|
|
62
|
+
readAttachmentStream,
|
|
63
|
+
sniffImageContentType,
|
|
64
|
+
isImageContentType,
|
|
65
|
+
MAX_ATTACHMENT_BYTES,
|
|
66
|
+
} from "./attachments";
|
|
67
|
+
import {
|
|
68
|
+
emitIssueEvent,
|
|
69
|
+
emitCommentEvent,
|
|
70
|
+
emitPingEvent,
|
|
71
|
+
listWebhooks,
|
|
72
|
+
createWebhook,
|
|
73
|
+
deleteWebhook,
|
|
74
|
+
setWebhookActive,
|
|
75
|
+
getWebhook,
|
|
76
|
+
listDeliveries,
|
|
77
|
+
type WebhookEventName,
|
|
78
|
+
} from "./webhooks";
|
|
79
|
+
import { classifyActor, type CommentView } from "./render/components";
|
|
80
|
+
import { buildWebhooksPage } from "./views/webhooks";
|
|
81
|
+
import { buildProjectMembersPage } from "./views/projectMembers";
|
|
82
|
+
import { buildProjectUpstreamsPage, trySetUpstreamUrls } from "./views/projectUpstreams";
|
|
83
|
+
import { handleGiteaApi } from "./giteaApi";
|
|
84
|
+
|
|
85
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
86
|
+
const STATIC_DIR = join(__dirname, "static");
|
|
87
|
+
|
|
88
|
+
const cfg: Config = loadConfig();
|
|
89
|
+
const opencode = new OpencodeClient(cfg);
|
|
90
|
+
|
|
91
|
+
function autoWireDaemon(projectId: number, origin: string): void {
|
|
92
|
+
const botLogin = cfg.daemonBotLogin.trim();
|
|
93
|
+
const hookUrl = cfg.daemonWebhookUrl.trim();
|
|
94
|
+
if (botLogin) {
|
|
95
|
+
try {
|
|
96
|
+
addProjectMember(projectId, botLogin, "writer");
|
|
97
|
+
} catch (e) {
|
|
98
|
+
log.warn("autoWireDaemon: addProjectMember failed", { botLogin, err: e as Error });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (hookUrl) {
|
|
102
|
+
try {
|
|
103
|
+
const target = hookUrl.replace(/\/$/, "") + "/webhook/gitea";
|
|
104
|
+
const exists = listWebhooks(projectId).some((w) => w.url === target);
|
|
105
|
+
if (!exists) {
|
|
106
|
+
createWebhook({
|
|
107
|
+
project_id: projectId,
|
|
108
|
+
url: target,
|
|
109
|
+
secret: cfg.daemonWebhookSecret,
|
|
110
|
+
events: ["issues", "issue_comment"],
|
|
111
|
+
});
|
|
112
|
+
emitPingEvent(projectId, origin);
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
log.warn("autoWireDaemon: createWebhook failed", { hookUrl, err: e as Error });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const SEC_HEADERS: Record<string, string> = {
|
|
121
|
+
"content-security-policy": `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'`,
|
|
122
|
+
"x-content-type-options": "nosniff",
|
|
123
|
+
"x-frame-options": "DENY",
|
|
124
|
+
"referrer-policy": "same-origin",
|
|
125
|
+
"permissions-policy": "()",
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const hlCss = loadHighlightCss();
|
|
129
|
+
|
|
130
|
+
function loadHighlightCss(): string {
|
|
131
|
+
const light = readFileSafe(join(__dirname, "..", "node_modules", "highlight.js", "styles", "github.css"));
|
|
132
|
+
const dark = readFileSafe(join(__dirname, "..", "node_modules", "highlight.js", "styles", "github-dark.css"));
|
|
133
|
+
if (!light && !dark) return "";
|
|
134
|
+
const darkRule = dark
|
|
135
|
+
? `@media (prefers-color-scheme:dark){${stripAtMedia(dark)}}`
|
|
136
|
+
: "";
|
|
137
|
+
return `${light}${darkRule}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readFileSafe(p: string): string {
|
|
141
|
+
try {
|
|
142
|
+
return readFileSync(p, "utf8");
|
|
143
|
+
} catch {
|
|
144
|
+
return "";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stripAtMedia(css: string): string {
|
|
149
|
+
return css.replace(/@media[^{]*\{([\s\S]*)\}\s*$/, "$1");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function json(data: unknown, status = 200): Response {
|
|
153
|
+
return new Response(JSON.stringify(data), {
|
|
154
|
+
status,
|
|
155
|
+
headers: { "content-type": "application/json; charset=utf-8", ...SEC_HEADERS },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function html(body: string, status = 200): Response {
|
|
160
|
+
return new Response(body, {
|
|
161
|
+
status,
|
|
162
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache", ...SEC_HEADERS },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const REPO_ISSUE_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)$/;
|
|
167
|
+
const REPO_LIST_RE = /^\/([^/]+)\/([^/]+)\/issues$/;
|
|
168
|
+
const REPO_NEW_RE = /^\/([^/]+)\/([^/]+)\/issues\/new$/;
|
|
169
|
+
const REPO_RE = /^\/([^/]+)\/([^/]+)$/;
|
|
170
|
+
const API_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/(page|since)$/;
|
|
171
|
+
const COMMENT_POST_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/comment$/;
|
|
172
|
+
const UPLOAD_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/upload$/;
|
|
173
|
+
const ATTACHMENT_RE = /^\/attachments\/([0-9a-fA-F-]+)$/;
|
|
174
|
+
const REPO_WEBHOOKS_RE = /^\/([^/]+)\/([^/]+)\/settings\/webhooks$/;
|
|
175
|
+
const REPO_MEMBERS_RE = /^\/([^/]+)\/([^/]+)\/settings\/members$/;
|
|
176
|
+
const REPO_MEMBER_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/([^/]+)\/(role|remove)$/;
|
|
177
|
+
const REPO_MEMBER_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/add$/;
|
|
178
|
+
const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
|
|
179
|
+
const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
|
|
180
|
+
const SESSIONS_RE = /^\/sessions$/;
|
|
181
|
+
const SESSION_VIEW_RE = /^\/sessions\/([A-Za-z0-9_-]+)$/;
|
|
182
|
+
const SESSION_SINCE_RE = /^\/api\/sessions\/([A-Za-z0-9_-]+)\/since$/;
|
|
183
|
+
const SESSION_BATCH_RE = /^\/api\/sessions\/([A-Za-z0-9_-]+)\/batch$/;
|
|
184
|
+
|
|
185
|
+
const server = Bun.serve({
|
|
186
|
+
port: cfg.port,
|
|
187
|
+
hostname: cfg.host,
|
|
188
|
+
async fetch(req, server) {
|
|
189
|
+
const url = new URL(req.url);
|
|
190
|
+
const ip = remoteAddr(req, server);
|
|
191
|
+
const start = Date.now();
|
|
192
|
+
const ctx = { authed: false, user: null as UserRow | null };
|
|
193
|
+
let res: Response;
|
|
194
|
+
try {
|
|
195
|
+
res = await handle(req, url, ip, ctx);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
res = html(errorPage("服务器错误", errMsg(e)), 500);
|
|
198
|
+
}
|
|
199
|
+
appendAccessLog(ip, req.method, url.pathname + url.search, res.status, ctx.authed, Date.now() - start);
|
|
200
|
+
return res;
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
void server;
|
|
204
|
+
|
|
205
|
+
function remoteAddr(
|
|
206
|
+
req: Request,
|
|
207
|
+
server: { requestIP(request: Request): { address: string } | null }
|
|
208
|
+
): string {
|
|
209
|
+
const xff = req.headers.get("x-forwarded-for");
|
|
210
|
+
if (xff) return (xff.split(",")[0] ?? "").trim();
|
|
211
|
+
const info = server.requestIP(req);
|
|
212
|
+
return info?.address ?? "unknown";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function appendAccessLog(ip: string, method: string, path: string, status: number, authed: boolean, ms: number): void {
|
|
216
|
+
const line = `${new Date().toISOString()} ${ip} ${method} ${path} → ${status} authed=${authed ? "yes" : "no"} ${ms}ms\n`;
|
|
217
|
+
log.debug("access", { ip, method, path, status, authed, ms });
|
|
218
|
+
try {
|
|
219
|
+
appendFileSync(cfg.accessLogPath, line);
|
|
220
|
+
} catch (e) {
|
|
221
|
+
log.warn("access-log write failed", { err: e as Error });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function staticAsset(name: string, type: string, req: Request): Response {
|
|
226
|
+
const f = Bun.file(join(STATIC_DIR, name));
|
|
227
|
+
const etag = `"${f.size}-${f.lastModified}"`;
|
|
228
|
+
if (req.headers.get("if-none-match") === etag) return new Response(null, { status: 304 });
|
|
229
|
+
return new Response(f, {
|
|
230
|
+
headers: { "content-type": type, "cache-control": "no-cache", etag, ...SEC_HEADERS },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const ttsStage = new Map<string, { text: string; url: string; voice: string; exp: number }>();
|
|
235
|
+
const TTS_STAGE_TTL = 600000;
|
|
236
|
+
const TTS_CHUNK_TIMEOUT = 60000;
|
|
237
|
+
|
|
238
|
+
function chunkTextTTS(text: string, max = 120): string[] {
|
|
239
|
+
text = text.replace(/\r/g, "").replace(/\n+/g, "。");
|
|
240
|
+
const out: string[] = [];
|
|
241
|
+
let buf = "";
|
|
242
|
+
const push = (s: string) => { const t = s.trim(); if (t) out.push(t); };
|
|
243
|
+
const hardCut = (s: string): string => {
|
|
244
|
+
while (s.length > max) {
|
|
245
|
+
let cut = 0;
|
|
246
|
+
for (const d of [",", ",", ";", ";", " ", " "]) {
|
|
247
|
+
const i = s.lastIndexOf(d, max);
|
|
248
|
+
if (i > cut) cut = i;
|
|
249
|
+
}
|
|
250
|
+
if (cut === 0) cut = max;
|
|
251
|
+
push(s.slice(0, cut));
|
|
252
|
+
s = s.slice(cut);
|
|
253
|
+
}
|
|
254
|
+
return s;
|
|
255
|
+
};
|
|
256
|
+
for (const s of text.replace(/\r/g, "").split(/(?<=[。!?\n.!?])/)) {
|
|
257
|
+
if ((buf + s).length > max) { push(buf); buf = ""; }
|
|
258
|
+
buf += hardCut(s);
|
|
259
|
+
if (buf.length > max) { push(buf); buf = ""; }
|
|
260
|
+
}
|
|
261
|
+
push(buf);
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean; user: UserRow | null }): Promise<Response> {
|
|
266
|
+
if (url.pathname === "/static/app.js") return staticAsset("app.js", "text/javascript; charset=utf-8", req);
|
|
267
|
+
if (url.pathname === "/static/session.js") return staticAsset("session.js", "text/javascript; charset=utf-8", req);
|
|
268
|
+
if (url.pathname === "/static/file.js") return staticAsset("file.js", "text/javascript; charset=utf-8", req);
|
|
269
|
+
if (url.pathname === "/static/tts.js") return staticAsset("tts.js", "text/javascript; charset=utf-8", req);
|
|
270
|
+
if (url.pathname === "/static/highlight.css") {
|
|
271
|
+
return new Response(hlCss, {
|
|
272
|
+
headers: { "content-type": "text/css; charset=utf-8", "cache-control": "no-cache", ...SEC_HEADERS },
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
if (url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico") {
|
|
276
|
+
return staticAsset("favicon.svg", "image/svg+xml", req);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (url.pathname === "/healthz") {
|
|
280
|
+
return new Response(
|
|
281
|
+
JSON.stringify({ ok: true, version: version(), uptime: uptimeSeconds() }),
|
|
282
|
+
{ status: 200, headers: { "content-type": "application/json", "cache-control": "no-store" } },
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (url.pathname === "/login") {
|
|
287
|
+
if (req.method === "POST") {
|
|
288
|
+
const form = await req.formData().catch(() => new FormData());
|
|
289
|
+
const next = sanitizeNext(String(form.get("next") ?? "/"));
|
|
290
|
+
if (!rateLimit(`login:${ip}`, 5, 5 / (15 * 60))) {
|
|
291
|
+
return html(loginHTML(next, "尝试过多,15 分钟后再试"), 429);
|
|
292
|
+
}
|
|
293
|
+
const login = String(form.get("login") ?? "").trim();
|
|
294
|
+
const password = String(form.get("password") ?? "");
|
|
295
|
+
const token = String(form.get("token") ?? "").trim();
|
|
296
|
+
|
|
297
|
+
// Token wins over login/password if both supplied (migration path).
|
|
298
|
+
let resolvedLogin: string | null = null;
|
|
299
|
+
if (token && token === cfg.authToken) {
|
|
300
|
+
resolvedLogin = cfg.operatorLogin;
|
|
301
|
+
} else if (login && password) {
|
|
302
|
+
const u = await verifyUserPassword(login, password);
|
|
303
|
+
if (u) resolvedLogin = u.login;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (resolvedLogin) {
|
|
307
|
+
const setCookie = await makeAuthCookieHeader(cfg, resolvedLogin);
|
|
308
|
+
return new Response(null, {
|
|
309
|
+
status: 302,
|
|
310
|
+
headers: { location: next, "set-cookie": setCookie },
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const err = token
|
|
314
|
+
? "token 不对"
|
|
315
|
+
: login || password
|
|
316
|
+
? "用户名或密码错误"
|
|
317
|
+
: "请填写用户名密码或共享 token";
|
|
318
|
+
return html(loginHTML(next, err), 401);
|
|
319
|
+
}
|
|
320
|
+
const next = sanitizeNext(url.searchParams.get("next") ?? "/");
|
|
321
|
+
return html(loginHTML(next));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (url.pathname === "/logout" && req.method === "POST") {
|
|
325
|
+
return new Response(null, {
|
|
326
|
+
status: 302,
|
|
327
|
+
headers: { location: "/login", "set-cookie": clearAuthCookieHeader(cfg) },
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const auth = await checkAuth(req, cfg, ip);
|
|
332
|
+
if (!auth.ok) {
|
|
333
|
+
const next = sanitizeNext(url.pathname + url.search);
|
|
334
|
+
return Response.redirect(`${url.origin}/login?next=${encodeURIComponent(next)}`, 302);
|
|
335
|
+
}
|
|
336
|
+
ctx.user = auth.user;
|
|
337
|
+
ctx.authed = true;
|
|
338
|
+
|
|
339
|
+
if (url.pathname.startsWith("/api/") && ctx.user?.kind !== "bot" && !rateLimit(`api:${ip}`, 60, 1)) {
|
|
340
|
+
return json({ error: "rate limited" }, 429);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Gitea-compatible REST shim (/api/v1/*). Must be checked BEFORE the legacy
|
|
344
|
+
// /api/* routes since those would 404 on /api/v1/* paths. The shim reuses
|
|
345
|
+
// the existing ctx.user (cookie or PAT auth already resolved above).
|
|
346
|
+
if (url.pathname.startsWith("/api/v1/")) {
|
|
347
|
+
const result = await handleGiteaApi(req, url, { user: ctx.user });
|
|
348
|
+
if (result) {
|
|
349
|
+
if (result.body === null) {
|
|
350
|
+
return new Response(null, { status: result.status, headers: SEC_HEADERS });
|
|
351
|
+
}
|
|
352
|
+
return json(result.body, result.status);
|
|
353
|
+
}
|
|
354
|
+
// fall through to 404 below if shim didn't match
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const att = url.pathname.match(ATTACHMENT_RE);
|
|
358
|
+
if (att) {
|
|
359
|
+
const [, uuid] = att;
|
|
360
|
+
if (!uuid) return new Response(null, { status: 400, headers: SEC_HEADERS });
|
|
361
|
+
const row = getAttachment(uuid);
|
|
362
|
+
if (!row) return new Response(null, { status: 404, headers: SEC_HEADERS });
|
|
363
|
+
const stream = readAttachmentStream(uuid);
|
|
364
|
+
if (!stream) return new Response(null, { status: 404, headers: SEC_HEADERS });
|
|
365
|
+
const headers = new Headers(SEC_HEADERS);
|
|
366
|
+
// Force download for non-image content types so uploaded .svg/.html can't execute
|
|
367
|
+
// in-origin (stored XSS). Images render inline; everything else is attachment.
|
|
368
|
+
headers.set(
|
|
369
|
+
"content-type",
|
|
370
|
+
isImageContentType(row.content_type) ? row.content_type : "application/octet-stream"
|
|
371
|
+
);
|
|
372
|
+
headers.set(
|
|
373
|
+
"content-disposition",
|
|
374
|
+
isImageContentType(row.content_type)
|
|
375
|
+
? `inline; filename="${row.filename.replace(/["\\]/g, "")}"`
|
|
376
|
+
: `attachment; filename="${row.filename.replace(/["\\]/g, "")}"`
|
|
377
|
+
);
|
|
378
|
+
headers.set("cache-control", "private, max-age=3600");
|
|
379
|
+
return new Response(stream.file, { status: 200, headers });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (url.pathname === "/") {
|
|
383
|
+
return Response.redirect(`${url.origin}/projects`, 302);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (url.pathname === "/projects") {
|
|
387
|
+
if (req.method === "POST") {
|
|
388
|
+
if (!cfg.writesEnabled) return html(errorPage("只读模式", "WORK_WRITES_ENABLED=false"), 403);
|
|
389
|
+
const form = await req.formData().catch(() => new FormData());
|
|
390
|
+
const f: Record<string, string | undefined> = {};
|
|
391
|
+
for (const [k, v] of form.entries()) f[k] = typeof v === "string" ? v : undefined;
|
|
392
|
+
const r = handleCreateProject(f);
|
|
393
|
+
if (r.projectId) {
|
|
394
|
+
ensureProjectBootstrapAdmin(r.projectId, ctx.user!.login);
|
|
395
|
+
autoWireDaemon(r.projectId, url.origin);
|
|
396
|
+
const q = new URLSearchParams({ created: "1" });
|
|
397
|
+
return Response.redirect(`${url.origin}${r.location}?${q}`, 303);
|
|
398
|
+
}
|
|
399
|
+
const q = new URLSearchParams({ err: r.error ?? "创建失败" });
|
|
400
|
+
return Response.redirect(`${url.origin}${r.location}?${q}`, 303);
|
|
401
|
+
}
|
|
402
|
+
const flashErr = url.searchParams.get("err");
|
|
403
|
+
const flash = flashErr ? { kind: "err" as const, msg: flashErr } : null;
|
|
404
|
+
return html(buildHome(ctx.user, flash));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (url.pathname === "/issues") {
|
|
408
|
+
const state = parseState(url.searchParams.get("state"));
|
|
409
|
+
const q = url.searchParams.get("q")?.trim() ?? "";
|
|
410
|
+
try {
|
|
411
|
+
return html(buildIssuesFeed(state, q));
|
|
412
|
+
} catch (e) {
|
|
413
|
+
return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (url.pathname.match(SESSIONS_RE)) {
|
|
418
|
+
const q = url.searchParams.get("q")?.trim() ?? "";
|
|
419
|
+
try {
|
|
420
|
+
const { html: body } = await buildSessionList(opencode, q);
|
|
421
|
+
return html(body);
|
|
422
|
+
} catch (e) {
|
|
423
|
+
return html(errorPage("加载失败", errMsg(e)), e instanceof OpencodeError ? e.status : 502);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const sessionView = url.pathname.match(SESSION_VIEW_RE);
|
|
428
|
+
if (sessionView) {
|
|
429
|
+
const [, sid] = sessionView;
|
|
430
|
+
if (!sid) return html(errorPage("404", "bad session path"), 404);
|
|
431
|
+
const desc = url.searchParams.get("asc") !== "1";
|
|
432
|
+
const all = url.searchParams.get("all") === "1";
|
|
433
|
+
const limit = Math.min(5000, Math.max(1, Number(url.searchParams.get("limit")) || 30));
|
|
434
|
+
try {
|
|
435
|
+
const { html: body } = await buildSessionView(opencode, sid, desc, cfg.collapseLines, limit, all);
|
|
436
|
+
return html(body);
|
|
437
|
+
} catch (e) {
|
|
438
|
+
const status = e instanceof OpencodeError ? e.status : 500;
|
|
439
|
+
if (status === 404 && /^ses_[0-9A-Za-z]{8,}/.test(sid)) {
|
|
440
|
+
return html(errorPage(
|
|
441
|
+
"会话尚未写入数据库",
|
|
442
|
+
`会话 ID ${sid} 在 opencode.db 里找不到。\n\n` +
|
|
443
|
+
`常见原因:daemon 把尚未完成的 opencode run 的 session ID 写进了评论(早期捕获),\n` +
|
|
444
|
+
`但 opencode 进程因 LLM 鉴权失败 / git clone 失败 / 其他错误退出前没把这条 session\n` +
|
|
445
|
+
`持久化到 DB。\n\n` +
|
|
446
|
+
`排查:docker logs ework-aio 看 opencode 子进程的报错;确认 OPENCODE_AI_API_KEY 已配置\n` +
|
|
447
|
+
`或 ~/.config/opencode/auth.json 存在。`
|
|
448
|
+
), 404);
|
|
449
|
+
}
|
|
450
|
+
return html(errorPage(status === 404 ? "找不到会话" : "加载失败", errMsg(e)), status);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (url.pathname === "/file") {
|
|
455
|
+
const rawPath = url.searchParams.get("path") ?? "";
|
|
456
|
+
const mode = url.searchParams.get("mode") ?? undefined;
|
|
457
|
+
const order = url.searchParams.get("order") ?? undefined;
|
|
458
|
+
try {
|
|
459
|
+
const view = url.searchParams.get("view") || undefined;
|
|
460
|
+
const sort = url.searchParams.get("sort") || undefined;
|
|
461
|
+
const tdir = url.searchParams.get("tdir") || undefined;
|
|
462
|
+
const { html: body } = buildFileView(cfg, rawPath, mode, order, view, sort, tdir);
|
|
463
|
+
return html(body);
|
|
464
|
+
} catch (e) {
|
|
465
|
+
const status = e instanceof FileViewError ? e.status : 500;
|
|
466
|
+
return html(errorPage(status === 404 ? "文件不存在" : "无法查看", errMsg(e)), status);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (url.pathname === "/file/raw" || url.pathname === "/file/dl") {
|
|
471
|
+
const rawPath = url.searchParams.get("path") ?? "";
|
|
472
|
+
const disp = url.pathname === "/file/dl" ? "attachment" : "inline";
|
|
473
|
+
try {
|
|
474
|
+
return serveRawFile(cfg, rawPath, disp);
|
|
475
|
+
} catch (e) {
|
|
476
|
+
const status = e instanceof FileViewError ? e.status : 500;
|
|
477
|
+
return html(errorPage(status === 404 ? "文件不存在" : "无法查看", errMsg(e)), status);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (url.pathname === "/api/file/since") {
|
|
482
|
+
const rawPath = url.searchParams.get("path") ?? "";
|
|
483
|
+
const after = Number(url.searchParams.get("after") ?? "0");
|
|
484
|
+
if (!Number.isFinite(after) || after < 0) return json({ error: "bad after" }, 400);
|
|
485
|
+
try {
|
|
486
|
+
const delta = readFileSince(cfg, rawPath, after);
|
|
487
|
+
return json(delta);
|
|
488
|
+
} catch (e) {
|
|
489
|
+
const status = e instanceof FileViewError ? e.status : 500;
|
|
490
|
+
return json({ error: errMsg(e) }, status);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (req.method === "POST" && url.pathname === "/api/translate") {
|
|
495
|
+
if (!rateLimit(`translate:${ip}`, 10, 10 / 60)) return json({ error: "rate limited" }, 429);
|
|
496
|
+
try {
|
|
497
|
+
const payload = (await req.json().catch(() => ({}))) as { text?: unknown };
|
|
498
|
+
const text = typeof payload.text === "string" ? payload.text : "";
|
|
499
|
+
if (!text.trim()) return json({ error: "text required" }, 400);
|
|
500
|
+
const translation = await translateText(cfg, text);
|
|
501
|
+
return json({ translation, html: renderMarkdown(translation) });
|
|
502
|
+
} catch (e) {
|
|
503
|
+
const status = e instanceof TranslateError ? e.status : 502;
|
|
504
|
+
return json({ error: errMsg(e) }, status);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (req.method === "POST" && url.pathname === "/api/translate/stream") {
|
|
509
|
+
if (!rateLimit(`translate:${ip}`, 10, 10 / 60)) return json({ error: "rate limited" }, 429);
|
|
510
|
+
const payload = (await req.json().catch(() => ({}))) as { text?: unknown };
|
|
511
|
+
const text = typeof payload.text === "string" ? payload.text : "";
|
|
512
|
+
if (!text.trim()) return json({ error: "text required" }, 400);
|
|
513
|
+
const stream = new ReadableStream({
|
|
514
|
+
async start(controller) {
|
|
515
|
+
const enc = new TextEncoder();
|
|
516
|
+
const send = (obj: unknown) => controller.enqueue(enc.encode(JSON.stringify(obj) + "\n"));
|
|
517
|
+
try {
|
|
518
|
+
let full = "";
|
|
519
|
+
for await (const chunk of translateTextStream(cfg, text)) {
|
|
520
|
+
full += chunk;
|
|
521
|
+
send({ d: chunk });
|
|
522
|
+
}
|
|
523
|
+
send({ h: renderMarkdown(full) });
|
|
524
|
+
} catch (e) {
|
|
525
|
+
send({ e: e instanceof Error ? e.message : String(e) });
|
|
526
|
+
} finally {
|
|
527
|
+
controller.close();
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
return new Response(stream, {
|
|
532
|
+
headers: { "content-type": "application/x-ndjson; charset=utf-8", ...SEC_HEADERS },
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (req.method === "GET" && url.pathname === "/api/tts/backends") {
|
|
537
|
+
return json(cfg.ttsBackends.map((b) => ({ id: b.id, label: b.label, voice: b.voice })));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (req.method === "POST" && url.pathname === "/api/tts") {
|
|
541
|
+
if (!rateLimit(`tts:${ip}`, 30, 30 / 60)) return json({ error: "rate limited" }, 429);
|
|
542
|
+
const payload = (await req.json().catch(() => ({}))) as { text?: unknown; backend?: unknown };
|
|
543
|
+
const text = typeof payload.text === "string" ? payload.text : "";
|
|
544
|
+
if (!text.trim()) return json({ error: "text required" }, 400);
|
|
545
|
+
if (text.length > 8000) return json({ error: "text too long" }, 413);
|
|
546
|
+
const backend = resolveTtsBackend(cfg, typeof payload.backend === "string" ? payload.backend : undefined);
|
|
547
|
+
if (!backend) return json({ error: "tts disabled" }, 503);
|
|
548
|
+
const id = crypto.randomUUID();
|
|
549
|
+
const now = Date.now();
|
|
550
|
+
if (ttsStage.size > 50) for (const [k, v] of ttsStage) if (v.exp < now) ttsStage.delete(k);
|
|
551
|
+
ttsStage.set(id, { text, url: backend.url, voice: backend.voice, exp: now + TTS_STAGE_TTL });
|
|
552
|
+
return json({ id });
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (req.method === "GET" && url.pathname.startsWith("/api/tts/stream/")) {
|
|
556
|
+
const id = decodeURIComponent(url.pathname.slice("/api/tts/stream/".length));
|
|
557
|
+
const entry = id && ttsStage.get(id);
|
|
558
|
+
if (!entry) return json({ error: "not found or expired" }, 404);
|
|
559
|
+
const segs = chunkTextTTS(entry.text);
|
|
560
|
+
if (segs.length === 0) return json({ error: "empty text" }, 400);
|
|
561
|
+
const upstream = `${entry.url.replace(/\/$/, "")}/audio/speech`;
|
|
562
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
563
|
+
async start(controller) {
|
|
564
|
+
try {
|
|
565
|
+
for (const seg of segs) {
|
|
566
|
+
const ctrl = new AbortController();
|
|
567
|
+
const timer = setTimeout(() => ctrl.abort(), TTS_CHUNK_TIMEOUT);
|
|
568
|
+
try {
|
|
569
|
+
const upRes = await fetch(upstream, {
|
|
570
|
+
method: "POST",
|
|
571
|
+
headers: { "content-type": "application/json" },
|
|
572
|
+
body: JSON.stringify({ input: seg, voice: entry.voice, response_format: "mp3", speed: cfg.ttsSpeed }),
|
|
573
|
+
signal: ctrl.signal,
|
|
574
|
+
});
|
|
575
|
+
if (!upRes.ok || !upRes.body) { controller.error(new Error(`tts upstream ${upRes.status}`)); return; }
|
|
576
|
+
const reader = upRes.body.getReader();
|
|
577
|
+
while (true) {
|
|
578
|
+
const { done, value } = await reader.read();
|
|
579
|
+
if (done) break;
|
|
580
|
+
controller.enqueue(value);
|
|
581
|
+
}
|
|
582
|
+
} finally {
|
|
583
|
+
clearTimeout(timer);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
controller.close();
|
|
587
|
+
} catch (e) {
|
|
588
|
+
controller.error(e instanceof Error ? e : new Error(String(e)));
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
const headers = new Headers(SEC_HEADERS);
|
|
593
|
+
headers.set("content-type", "audio/mpeg");
|
|
594
|
+
headers.set("cache-control", "no-cache");
|
|
595
|
+
return new Response(stream, { status: 200, headers });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (url.pathname === "/settings") {
|
|
599
|
+
if (req.method === "GET") {
|
|
600
|
+
return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!).html);
|
|
601
|
+
}
|
|
602
|
+
if (req.method === "POST") {
|
|
603
|
+
if (!rateLimit(`settings:${ip}`, 10, 10 / 60)) return html(errorPage("太快了", "请稍后再试"), 429);
|
|
604
|
+
const form = await req.formData();
|
|
605
|
+
let bad = "";
|
|
606
|
+
for (const key of DB_OVERRIDABLE) {
|
|
607
|
+
const v = form.get(String(key));
|
|
608
|
+
if (typeof v !== "string") continue;
|
|
609
|
+
if (parseOverride(key, v) === null) { bad = String(key); break; }
|
|
610
|
+
setConfig(String(key), v);
|
|
611
|
+
}
|
|
612
|
+
if (bad) return html(errorPage(`无效的字段: ${bad}`, "请检查输入后重试"), 400);
|
|
613
|
+
Object.assign(cfg, loadConfig());
|
|
614
|
+
const rh = new Headers(SEC_HEADERS);
|
|
615
|
+
rh.set("location", "/settings?saved=1");
|
|
616
|
+
return new Response(null, { status: 303, headers: rh });
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (url.pathname === "/me") {
|
|
621
|
+
const me = ctx.user!;
|
|
622
|
+
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
623
|
+
const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
|
|
624
|
+
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
|
|
625
|
+
return html(buildMePage(me, flash));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (url.pathname === "/me/password" && req.method === "POST") {
|
|
629
|
+
const me = ctx.user!;
|
|
630
|
+
if (!rateLimit(`pw:${ip}`, 10, 10 / 60)) {
|
|
631
|
+
return Response.redirect(`${url.origin}/me?err=${encodeURIComponent("太快了,稍后再试")}`, 303);
|
|
632
|
+
}
|
|
633
|
+
const form = await req.formData().catch(() => new FormData());
|
|
634
|
+
const oldPw = String(form.get("old") ?? "");
|
|
635
|
+
const newPw = String(form.get("new") ?? "");
|
|
636
|
+
const confirm = String(form.get("confirm") ?? "");
|
|
637
|
+
if (me.password_hash) {
|
|
638
|
+
const ok = await verifyUserPassword(me.login, oldPw);
|
|
639
|
+
if (!ok) return Response.redirect(`${url.origin}/me?err=${encodeURIComponent("当前密码错误")}`, 303);
|
|
640
|
+
}
|
|
641
|
+
if (newPw !== confirm) {
|
|
642
|
+
return Response.redirect(`${url.origin}/me?err=${encodeURIComponent("两次新密码不一致")}`, 303);
|
|
643
|
+
}
|
|
644
|
+
try {
|
|
645
|
+
await setUserPassword(me.login, newPw);
|
|
646
|
+
} catch (e) {
|
|
647
|
+
return Response.redirect(`${url.origin}/me?err=${encodeURIComponent(errMsg(e))}`, 303);
|
|
648
|
+
}
|
|
649
|
+
return Response.redirect(`${url.origin}/me?ok=1&ok_msg=${encodeURIComponent("密码已更新")}`, 303);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (url.pathname === "/me/tokens") {
|
|
653
|
+
const me = ctx.user!;
|
|
654
|
+
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
655
|
+
const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
|
|
656
|
+
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
|
|
657
|
+
return html(buildTokensPage(me, listPatsForUser(me.login), flash));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (url.pathname === "/me/tokens/create" && req.method === "POST") {
|
|
661
|
+
const me = ctx.user!;
|
|
662
|
+
if (!rateLimit(`pat-create:${ip}`, 10, 10 / 60)) {
|
|
663
|
+
return Response.redirect(`${url.origin}/me/tokens?err=${encodeURIComponent("太快了,稍后再试")}`, 303);
|
|
664
|
+
}
|
|
665
|
+
const form = await req.formData().catch(() => new FormData());
|
|
666
|
+
const name = String(form.get("name") ?? "").trim();
|
|
667
|
+
const expiresAt = String(form.get("expires_at") ?? "").trim() || null;
|
|
668
|
+
const ipAllowRaw = String(form.get("ip_allowlist") ?? "").trim();
|
|
669
|
+
const ipAllowlist = ipAllowRaw ? ipAllowRaw.split(/[\s,]+/).filter(Boolean) : undefined;
|
|
670
|
+
try {
|
|
671
|
+
const result = await createPat({ user_login: me.login, name, expires_at: expiresAt, ip_allowlist: ipAllowlist });
|
|
672
|
+
return html(buildTokenCreatedPage(me, result.plaintext, result.row.name));
|
|
673
|
+
} catch (e) {
|
|
674
|
+
return Response.redirect(`${url.origin}/me/tokens?err=${encodeURIComponent(errMsg(e))}`, 303);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const patRevoke = url.pathname.match(/^\/me\/tokens\/(\d+)\/revoke$/);
|
|
679
|
+
if (patRevoke && req.method === "POST") {
|
|
680
|
+
const me = ctx.user!;
|
|
681
|
+
const id = Number(patRevoke[1]);
|
|
682
|
+
try {
|
|
683
|
+
revokePat(id, me.login);
|
|
684
|
+
return Response.redirect(`${url.origin}/me/tokens?ok=1&ok_msg=${encodeURIComponent("已吊销")}`, 303);
|
|
685
|
+
} catch (e) {
|
|
686
|
+
return Response.redirect(`${url.origin}/me/tokens?err=${encodeURIComponent(errMsg(e))}`, 303);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (url.pathname.startsWith("/admin/") && ctx.user!.is_admin !== 1) {
|
|
691
|
+
return html(errorPage("无权限", "需要管理员账户"), 403);
|
|
692
|
+
}
|
|
693
|
+
if (url.pathname === "/admin/users") {
|
|
694
|
+
if (req.method === "GET") {
|
|
695
|
+
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
696
|
+
const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
|
|
697
|
+
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
|
|
698
|
+
return html(buildAdminUsersPage(ctx.user!, listUsers(), flash));
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (url.pathname === "/admin/tokens") {
|
|
703
|
+
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
704
|
+
const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
|
|
705
|
+
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
|
|
706
|
+
return html(buildAdminTokensPage(ctx.user!, listAllPatsWithUsers(), flash));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const adminPatRevoke = url.pathname.match(/^\/admin\/tokens\/(\d+)\/revoke$/);
|
|
710
|
+
if (adminPatRevoke && req.method === "POST") {
|
|
711
|
+
const id = Number(adminPatRevoke[1]);
|
|
712
|
+
try {
|
|
713
|
+
revokePatAsAdmin(id);
|
|
714
|
+
return Response.redirect(`${url.origin}/admin/tokens?ok=1&ok_msg=${encodeURIComponent("已吊销")}`, 303);
|
|
715
|
+
} catch (e) {
|
|
716
|
+
return Response.redirect(`${url.origin}/admin/tokens?err=${encodeURIComponent(errMsg(e))}`, 303);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const ttsIdRe = /^[A-Za-z0-9_-]+$/;
|
|
721
|
+
const ttsRedir = (kind: "ok" | "err", msg: string): Response =>
|
|
722
|
+
Response.redirect(`${url.origin}/admin/tts-backends?${kind === "ok" ? "ok=1&ok_msg" : "err"}=${encodeURIComponent(msg)}`, 303);
|
|
723
|
+
|
|
724
|
+
if (url.pathname === "/admin/tts-backends") {
|
|
725
|
+
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
726
|
+
const flashMsg = flashKind === "ok" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
|
|
727
|
+
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
|
|
728
|
+
return html(buildTtsBackendsPage(ctx.user!, cfg.ttsBackends, cfg.ttsDefaultBackend, flash));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (url.pathname === "/admin/tts-backends/add" && req.method === "POST") {
|
|
732
|
+
if (!rateLimit(`admin:${ip}`, 20, 20 / 60)) return ttsRedir("err", "太快了");
|
|
733
|
+
const form = await req.formData();
|
|
734
|
+
const id = String(form.get("id") ?? "").trim();
|
|
735
|
+
const label = String(form.get("label") ?? "").trim();
|
|
736
|
+
const beUrl = String(form.get("url") ?? "").trim();
|
|
737
|
+
const voice = String(form.get("voice") ?? "").trim();
|
|
738
|
+
if (!ttsIdRe.test(id)) return ttsRedir("err", "ID 只能含字母数字 _ -");
|
|
739
|
+
if (!label) return ttsRedir("err", "label 必填");
|
|
740
|
+
if (cfg.ttsBackends.some((b) => b.id === id)) return ttsRedir("err", `ID '${id}' 已存在`);
|
|
741
|
+
const list = [...cfg.ttsBackends, { id, label, url: beUrl, voice }];
|
|
742
|
+
setConfig("ttsBackends", JSON.stringify(list));
|
|
743
|
+
Object.assign(cfg, loadConfig());
|
|
744
|
+
return ttsRedir("ok", `已添加 ${id}`);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const ttsUpdate = url.pathname.match(/^\/admin\/tts-backends\/([A-Za-z0-9_-]+)\/update$/);
|
|
748
|
+
if (ttsUpdate && req.method === "POST") {
|
|
749
|
+
if (!rateLimit(`admin:${ip}`, 20, 20 / 60)) return ttsRedir("err", "太快了");
|
|
750
|
+
const oldId = ttsUpdate[1]!;
|
|
751
|
+
const form = await req.formData();
|
|
752
|
+
const newId = String(form.get("id") ?? "").trim();
|
|
753
|
+
const label = String(form.get("label") ?? "").trim();
|
|
754
|
+
const beUrl = String(form.get("url") ?? "").trim();
|
|
755
|
+
const voice = String(form.get("voice") ?? "").trim();
|
|
756
|
+
if (!ttsIdRe.test(newId)) return ttsRedir("err", "ID 只能含字母数字 _ -");
|
|
757
|
+
if (!label) return ttsRedir("err", "label 必填");
|
|
758
|
+
const list = cfg.ttsBackends;
|
|
759
|
+
const idx = list.findIndex((b) => b.id === oldId);
|
|
760
|
+
if (idx < 0) return ttsRedir("err", `后端 '${oldId}' 不存在`);
|
|
761
|
+
if (newId !== oldId && list.some((b) => b.id === newId)) return ttsRedir("err", `ID '${newId}' 已被占用`);
|
|
762
|
+
list[idx] = { id: newId, label, url: beUrl, voice };
|
|
763
|
+
setConfig("ttsBackends", JSON.stringify(list));
|
|
764
|
+
if (cfg.ttsDefaultBackend === oldId && newId !== oldId) setConfig("ttsDefaultBackend", newId);
|
|
765
|
+
Object.assign(cfg, loadConfig());
|
|
766
|
+
return ttsRedir("ok", `已更新 ${newId}`);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const ttsDelete = url.pathname.match(/^\/admin\/tts-backends\/([A-Za-z0-9_-]+)\/delete$/);
|
|
770
|
+
if (ttsDelete && req.method === "POST") {
|
|
771
|
+
if (!rateLimit(`admin:${ip}`, 20, 20 / 60)) return ttsRedir("err", "太快了");
|
|
772
|
+
const id = ttsDelete[1]!;
|
|
773
|
+
if (cfg.ttsDefaultBackend === id) return ttsRedir("err", "不能删除默认后端,先在设置里改默认");
|
|
774
|
+
const list = cfg.ttsBackends;
|
|
775
|
+
const idx = list.findIndex((b) => b.id === id);
|
|
776
|
+
if (idx < 0) return ttsRedir("err", `后端 '${id}' 不存在`);
|
|
777
|
+
list.splice(idx, 1);
|
|
778
|
+
setConfig("ttsBackends", JSON.stringify(list));
|
|
779
|
+
Object.assign(cfg, loadConfig());
|
|
780
|
+
return ttsRedir("ok", `已删除 ${id}`);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (url.pathname === "/admin/users/create" && req.method === "POST") {
|
|
784
|
+
if (!rateLimit(`admin:${ip}`, 20, 20 / 60)) {
|
|
785
|
+
return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("太快了")}`, 303);
|
|
786
|
+
}
|
|
787
|
+
const form = await req.formData().catch(() => new FormData());
|
|
788
|
+
const login = String(form.get("login") ?? "").trim();
|
|
789
|
+
const password = String(form.get("password") ?? "");
|
|
790
|
+
const email = String(form.get("email") ?? "").trim() || undefined;
|
|
791
|
+
const kind = (String(form.get("kind") ?? "human") as "human" | "bot" | "system") || "human";
|
|
792
|
+
const isAdmin = form.get("is_admin") === "1";
|
|
793
|
+
try {
|
|
794
|
+
const u = await createUser({ login, password, email, kind, is_admin: isAdmin });
|
|
795
|
+
return Response.redirect(`${url.origin}/admin/users?ok=1&ok_msg=${encodeURIComponent(`已创建用户 ${u.login}`)}`, 303);
|
|
796
|
+
} catch (e) {
|
|
797
|
+
return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent(errMsg(e))}`, 303);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const userActionRe = url.pathname.match(/^\/admin\/users\/([^/]+)\/(reset-password|toggle-admin|toggle-active)$/);
|
|
802
|
+
if (userActionRe && req.method === "POST") {
|
|
803
|
+
const [, targetLogin, action] = userActionRe;
|
|
804
|
+
if (!(targetLogin && action)) return html(errorPage("bad path", ""), 400);
|
|
805
|
+
if (isReservedSystemLogin(targetLogin, cfg)) {
|
|
806
|
+
return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("系统保留账户不能修改")}`, 303);
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
if (action === "reset-password") {
|
|
810
|
+
const form = await req.formData().catch(() => new FormData());
|
|
811
|
+
const pw = String(form.get("password") ?? "");
|
|
812
|
+
await setUserPassword(targetLogin, pw);
|
|
813
|
+
return Response.redirect(`${url.origin}/admin/users?ok=1&ok_msg=${encodeURIComponent(`${targetLogin} 密码已重置`)}`, 303);
|
|
814
|
+
}
|
|
815
|
+
if (action === "toggle-admin") {
|
|
816
|
+
if (targetLogin === ctx.user!.login) {
|
|
817
|
+
return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能改自己的 admin 状态")}`, 303);
|
|
818
|
+
}
|
|
819
|
+
const u = getUserByLogin(targetLogin);
|
|
820
|
+
if (!u) throw new StoreError(404, "用户不存在");
|
|
821
|
+
if (u.is_admin === 1 && countAdmins() <= 1) {
|
|
822
|
+
return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("系统至少要保留一个 admin")}`, 303);
|
|
823
|
+
}
|
|
824
|
+
const updated = updateUser(targetLogin, { is_admin: u.is_admin !== 1 });
|
|
825
|
+
return Response.redirect(`${url.origin}/admin/users?ok=1&ok_msg=${encodeURIComponent(`${updated.login} ${updated.is_admin ? "已设为 admin" : "已取消 admin"}`)}`, 303);
|
|
826
|
+
}
|
|
827
|
+
if (action === "toggle-active") {
|
|
828
|
+
if (targetLogin === ctx.user!.login) {
|
|
829
|
+
return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能禁用自己的账户")}`, 303);
|
|
830
|
+
}
|
|
831
|
+
const u = getUserByLogin(targetLogin);
|
|
832
|
+
if (!u) throw new StoreError(404, "用户不存在");
|
|
833
|
+
if (u.is_active === 1 && u.is_admin === 1 && countAdmins() <= 1) {
|
|
834
|
+
return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能禁用最后一个 admin")}`, 303);
|
|
835
|
+
}
|
|
836
|
+
const updated = updateUser(targetLogin, { is_active: u.is_active !== 1 });
|
|
837
|
+
return Response.redirect(`${url.origin}/admin/users?ok=1&ok_msg=${encodeURIComponent(`${updated.login} ${updated.is_active ? "已启用" : "已禁用"}`)}`, 303);
|
|
838
|
+
}
|
|
839
|
+
return html(errorPage("bad action", ""), 400);
|
|
840
|
+
} catch (e) {
|
|
841
|
+
return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent(errMsg(e))}`, 303);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const sinceApi = url.pathname.match(SESSION_SINCE_RE);
|
|
846
|
+
if (sinceApi) {
|
|
847
|
+
const [, sid] = sinceApi;
|
|
848
|
+
if (!sid) return json({ error: "bad session path" }, 400);
|
|
849
|
+
const sinceNum = Number(url.searchParams.get("since") ?? "0");
|
|
850
|
+
const since = Number.isFinite(sinceNum) ? sinceNum : 0;
|
|
851
|
+
try {
|
|
852
|
+
const data = await opencode.exportSession(sid);
|
|
853
|
+
const { items, lastCreated } = renderNewMessages(data, since, cfg.collapseLines);
|
|
854
|
+
return json({ items, lastCreated });
|
|
855
|
+
} catch (e) {
|
|
856
|
+
return json({ error: errMsg(e) }, e instanceof OpencodeError ? e.status : 502);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const batchApi = url.pathname.match(SESSION_BATCH_RE);
|
|
861
|
+
if (batchApi) {
|
|
862
|
+
const [, sid] = batchApi;
|
|
863
|
+
if (!sid) return json({ error: "bad session path" }, 400);
|
|
864
|
+
const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
|
|
865
|
+
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get("limit")) || 30));
|
|
866
|
+
const desc = url.searchParams.get("asc") !== "1";
|
|
867
|
+
try {
|
|
868
|
+
const data = await opencode.exportSession(sid);
|
|
869
|
+
const batch = renderBatchHTML(data, offset, limit, desc, cfg.collapseLines);
|
|
870
|
+
return json(batch);
|
|
871
|
+
} catch (e) {
|
|
872
|
+
return json({ error: errMsg(e) }, e instanceof OpencodeError ? e.status : 502);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (req.method === "POST") {
|
|
877
|
+
if (!cfg.writesEnabled) return json({ error: "writes disabled" }, 403);
|
|
878
|
+
const up = url.pathname.match(UPLOAD_RE);
|
|
879
|
+
if (up) {
|
|
880
|
+
const [, owner, repo, numStr] = up;
|
|
881
|
+
if (!(owner && repo && numStr)) return json({ error: "bad path" }, 400);
|
|
882
|
+
const cl = Number(req.headers.get("content-length") ?? "0");
|
|
883
|
+
if (cl > MAX_ATTACHMENT_BYTES) return json({ error: "file too large (max 20MB)" }, 413);
|
|
884
|
+
const number = Number(numStr);
|
|
885
|
+
try {
|
|
886
|
+
const project = getProject(owner, repo);
|
|
887
|
+
if (!project) return json({ error: "project not found" }, 404);
|
|
888
|
+
if (!canWriteProject(project.id, ctx.user)) return json({ error: "forbidden: needs writer role on project" }, 403);
|
|
889
|
+
const issue = getIssueWithMeta(project.id, number);
|
|
890
|
+
if (!issue) return json({ error: "issue not found" }, 404);
|
|
891
|
+
const form = await req.formData().catch(() => null);
|
|
892
|
+
const file = form?.get("attachment");
|
|
893
|
+
if (!(file instanceof File)) return json({ error: "attachment required" }, 400);
|
|
894
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
895
|
+
if (bytes.length > MAX_ATTACHMENT_BYTES) return json({ error: "file too large (max 20MB)" }, 413);
|
|
896
|
+
const filename = file.name || "upload.bin";
|
|
897
|
+
const contentType = file.type || sniffImageContentType(filename);
|
|
898
|
+
const uuid = newAttachmentUUID();
|
|
899
|
+
const blobPath = saveAttachmentBlob(uuid, bytes);
|
|
900
|
+
createAttachment({
|
|
901
|
+
uuid,
|
|
902
|
+
issue_id: issue.id,
|
|
903
|
+
filename,
|
|
904
|
+
content_type: contentType,
|
|
905
|
+
size: bytes.length,
|
|
906
|
+
blob_path: blobPath,
|
|
907
|
+
uploaded_by: ctx.user!.login,
|
|
908
|
+
});
|
|
909
|
+
const isImg = isImageContentType(contentType);
|
|
910
|
+
const markdown = isImg
|
|
911
|
+
? ``
|
|
912
|
+
: `[${filename}](/attachments/${uuid})`;
|
|
913
|
+
return json({ uuid, name: filename, markdown });
|
|
914
|
+
} catch (e) {
|
|
915
|
+
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const cp = url.pathname.match(COMMENT_POST_RE);
|
|
920
|
+
if (cp) {
|
|
921
|
+
const [, owner, repo, numStr] = cp;
|
|
922
|
+
if (!(owner && repo && numStr)) return json({ error: "bad path" }, 400);
|
|
923
|
+
const number = Number(numStr);
|
|
924
|
+
try {
|
|
925
|
+
const payload = (await req.json().catch(() => ({}))) as { body?: unknown; close?: unknown; reopen?: unknown };
|
|
926
|
+
const body = typeof payload.body === "string" ? payload.body : "";
|
|
927
|
+
const hasBody = body.trim().length > 0;
|
|
928
|
+
const wantsStateChange = payload.close === true || payload.reopen === true;
|
|
929
|
+
if (!hasBody && !wantsStateChange) return json({ error: "body required" }, 400);
|
|
930
|
+
if (body.length > 65536) return json({ error: "body too long" }, 413);
|
|
931
|
+
const project = getProject(owner, repo);
|
|
932
|
+
if (!project) return json({ error: "project not found" }, 404);
|
|
933
|
+
if (!canWriteProject(project.id, ctx.user)) return json({ error: "forbidden: needs writer role on project" }, 403);
|
|
934
|
+
const issue = getIssueWithMeta(project.id, number);
|
|
935
|
+
if (!issue) return json({ error: "issue not found" }, 404);
|
|
936
|
+
let view: CommentView | null = null;
|
|
937
|
+
if (hasBody) {
|
|
938
|
+
const c = postComment(issue.id, body, ctx.user!.login);
|
|
939
|
+
view = {
|
|
940
|
+
id: c.id,
|
|
941
|
+
tag: classifyActor(c.body, c.author_kind),
|
|
942
|
+
login: c.author,
|
|
943
|
+
avatar: "",
|
|
944
|
+
created_at: c.created_at,
|
|
945
|
+
body_html: renderMarkdown(c.body),
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
let closed = false;
|
|
949
|
+
let reopened = false;
|
|
950
|
+
if (payload.close === true) {
|
|
951
|
+
setIssueState(issue.id, "closed");
|
|
952
|
+
closed = true;
|
|
953
|
+
void emitIssueEvent(project.id, issue.id, "closed", url.origin);
|
|
954
|
+
} else if (payload.reopen === true) {
|
|
955
|
+
setIssueState(issue.id, "open");
|
|
956
|
+
reopened = true;
|
|
957
|
+
void emitIssueEvent(project.id, issue.id, "reopened", url.origin);
|
|
958
|
+
}
|
|
959
|
+
if (view) {
|
|
960
|
+
void emitCommentEvent(project.id, issue.id, view.id, url.origin);
|
|
961
|
+
}
|
|
962
|
+
return json({ comment: view, closed, reopened });
|
|
963
|
+
} catch (e) {
|
|
964
|
+
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const ci = url.pathname.match(REPO_LIST_RE);
|
|
969
|
+
if (ci) {
|
|
970
|
+
const [, owner, repo] = ci;
|
|
971
|
+
if (!(owner && repo)) return json({ error: "bad path" }, 400);
|
|
972
|
+
try {
|
|
973
|
+
const form = await req.formData().catch(() => new FormData());
|
|
974
|
+
const title = String(form.get("title") ?? "").trim();
|
|
975
|
+
const body = String(form.get("body") ?? "");
|
|
976
|
+
if (!title) return html(errorPage("标题不能为空", "回到上一页填写标题后重试"), 400);
|
|
977
|
+
let project = getProject(owner, repo);
|
|
978
|
+
let createdProject = false;
|
|
979
|
+
if (!project) {
|
|
980
|
+
project = createProjectSafe(owner, repo);
|
|
981
|
+
createdProject = true;
|
|
982
|
+
}
|
|
983
|
+
if (!canWriteProject(project.id, ctx.user)) {
|
|
984
|
+
return html(errorPage("无权限", "需要该项目 writer 及以上角色才能创建 issue"), 403);
|
|
985
|
+
}
|
|
986
|
+
if (createdProject) {
|
|
987
|
+
ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
|
|
988
|
+
autoWireDaemon(project.id, url.origin);
|
|
989
|
+
}
|
|
990
|
+
const issue = createIssue(project.id, title, body, ctx.user!.login);
|
|
991
|
+
void emitIssueEvent(project.id, issue.id, "opened", url.origin);
|
|
992
|
+
return Response.redirect(
|
|
993
|
+
`${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issue.number}`,
|
|
994
|
+
303
|
|
995
|
+
);
|
|
996
|
+
} catch (e) {
|
|
997
|
+
return html(errorPage("创建失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const whCreate = url.pathname.match(REPO_WEBHOOKS_RE);
|
|
1002
|
+
if (whCreate) {
|
|
1003
|
+
const [, owner, repo] = whCreate;
|
|
1004
|
+
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
|
|
1005
|
+
try {
|
|
1006
|
+
const project = getProject(owner, repo);
|
|
1007
|
+
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1008
|
+
if (!canAdminProject(project.id, ctx.user)) {
|
|
1009
|
+
return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
|
|
1010
|
+
}
|
|
1011
|
+
const form = await req.formData().catch(() => new FormData());
|
|
1012
|
+
const url_ = String(form.get("url") ?? "").trim();
|
|
1013
|
+
const secret = String(form.get("secret") ?? "");
|
|
1014
|
+
const events = form.getAll("events") as string[];
|
|
1015
|
+
const validEvents = (events.length > 0 ? events : ["issues", "issue_comment"])
|
|
1016
|
+
.filter((e): e is WebhookEventName => e === "issues" || e === "issue_comment");
|
|
1017
|
+
const wh = createWebhook({
|
|
1018
|
+
project_id: project.id,
|
|
1019
|
+
url: url_,
|
|
1020
|
+
secret,
|
|
1021
|
+
events: validEvents,
|
|
1022
|
+
});
|
|
1023
|
+
return Response.redirect(
|
|
1024
|
+
`${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/webhooks#wh-${wh.id}`,
|
|
1025
|
+
303
|
|
1026
|
+
);
|
|
1027
|
+
} catch (e) {
|
|
1028
|
+
return html(errorPage("添加失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const memberAdd = url.pathname.match(REPO_MEMBER_ADD_RE);
|
|
1033
|
+
if (memberAdd) {
|
|
1034
|
+
const [, owner, repo] = memberAdd;
|
|
1035
|
+
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
|
|
1036
|
+
const project = getProject(owner, repo);
|
|
1037
|
+
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1038
|
+
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/members`;
|
|
1039
|
+
if (!canAdminProject(project.id, ctx.user)) {
|
|
1040
|
+
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
1041
|
+
}
|
|
1042
|
+
const form = await req.formData().catch(() => new FormData());
|
|
1043
|
+
const login = String(form.get("login") ?? "").trim();
|
|
1044
|
+
const role = String(form.get("role") ?? "writer") as ProjectRole;
|
|
1045
|
+
if (!["reader", "writer", "admin"].includes(role)) {
|
|
1046
|
+
return Response.redirect(`${back}?err=${encodeURIComponent("非法角色")}`, 303);
|
|
1047
|
+
}
|
|
1048
|
+
try {
|
|
1049
|
+
addProjectMember(project.id, login, role);
|
|
1050
|
+
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已添加 ${login} 为 ${role}`)}`, 303);
|
|
1051
|
+
} catch (e) {
|
|
1052
|
+
return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const memberAction = url.pathname.match(REPO_MEMBER_ACTION_RE);
|
|
1057
|
+
if (memberAction) {
|
|
1058
|
+
const [, owner, repo, targetLogin, action] = memberAction;
|
|
1059
|
+
if (!(owner && repo && targetLogin && action)) return html(errorPage("bad path", ""), 400);
|
|
1060
|
+
const project = getProject(owner, repo);
|
|
1061
|
+
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1062
|
+
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/members`;
|
|
1063
|
+
if (!canAdminProject(project.id, ctx.user)) {
|
|
1064
|
+
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
1065
|
+
}
|
|
1066
|
+
try {
|
|
1067
|
+
if (action === "role") {
|
|
1068
|
+
const form = await req.formData().catch(() => new FormData());
|
|
1069
|
+
const role = String(form.get("role") ?? "") as ProjectRole;
|
|
1070
|
+
if (!["reader", "writer", "admin"].includes(role)) {
|
|
1071
|
+
return Response.redirect(`${back}?err=${encodeURIComponent("非法角色")}`, 303);
|
|
1072
|
+
}
|
|
1073
|
+
// Guard last admin: don't let the only project admin demote themselves.
|
|
1074
|
+
const current = getProjectMembership(project.id, targetLogin);
|
|
1075
|
+
if (
|
|
1076
|
+
current?.role === "admin" &&
|
|
1077
|
+
role !== "admin" &&
|
|
1078
|
+
countProjectAdmins(project.id) <= 1
|
|
1079
|
+
) {
|
|
1080
|
+
return Response.redirect(`${back}?err=${encodeURIComponent("最后一个 admin 不能降级")}`, 303);
|
|
1081
|
+
}
|
|
1082
|
+
setProjectMemberRole(project.id, targetLogin, role);
|
|
1083
|
+
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`${targetLogin} → ${role}`)}`, 303);
|
|
1084
|
+
}
|
|
1085
|
+
if (action === "remove") {
|
|
1086
|
+
const current = getProjectMembership(project.id, targetLogin);
|
|
1087
|
+
if (current?.role === "admin" && countProjectAdmins(project.id) <= 1) {
|
|
1088
|
+
return Response.redirect(`${back}?err=${encodeURIComponent("最后一个 admin 不能移除")}`, 303);
|
|
1089
|
+
}
|
|
1090
|
+
removeProjectMember(project.id, targetLogin);
|
|
1091
|
+
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已移除 ${targetLogin}`)}`, 303);
|
|
1092
|
+
}
|
|
1093
|
+
return html(errorPage("bad action", ""), 400);
|
|
1094
|
+
} catch (e) {
|
|
1095
|
+
return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const whAction = url.pathname.match(WH_ACTION_RE);
|
|
1100
|
+
if (whAction) {
|
|
1101
|
+
const [, idStr, action] = whAction;
|
|
1102
|
+
const id = Number(idStr || "0");
|
|
1103
|
+
if (!id) return html(errorPage("bad webhook id", ""), 400);
|
|
1104
|
+
const wh = getWebhook(id);
|
|
1105
|
+
if (!wh) return html(errorPage("webhook 不存在", ""), 404);
|
|
1106
|
+
const project = getProjectById(wh.project_id);
|
|
1107
|
+
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1108
|
+
if (!canAdminProject(project.id, ctx.user)) {
|
|
1109
|
+
return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
|
|
1110
|
+
}
|
|
1111
|
+
const back = `${url.origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/webhooks`;
|
|
1112
|
+
if (action === "delete") {
|
|
1113
|
+
deleteWebhook(id);
|
|
1114
|
+
} else if (action === "toggle") {
|
|
1115
|
+
const form = await req.formData().catch(() => new FormData());
|
|
1116
|
+
const wantActive = form.get("active") === "1";
|
|
1117
|
+
setWebhookActive(id, wantActive);
|
|
1118
|
+
} else if (action === "test") {
|
|
1119
|
+
void emitPingEvent(wh.project_id, url.origin);
|
|
1120
|
+
}
|
|
1121
|
+
return Response.redirect(back, 303);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const upstreamSave = url.pathname.match(REPO_UPSTREAMS_RE);
|
|
1125
|
+
if (upstreamSave) {
|
|
1126
|
+
const [, owner, repo] = upstreamSave;
|
|
1127
|
+
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
|
|
1128
|
+
const project = getProject(owner, repo);
|
|
1129
|
+
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1130
|
+
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/upstreams`;
|
|
1131
|
+
if (!canAdminProject(project.id, ctx.user)) {
|
|
1132
|
+
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
1133
|
+
}
|
|
1134
|
+
const form = await req.formData().catch(() => new FormData());
|
|
1135
|
+
const raw = String(form.get("urls") ?? "");
|
|
1136
|
+
const result = trySetUpstreamUrls(project.id, raw);
|
|
1137
|
+
if (result.ok) {
|
|
1138
|
+
return Response.redirect(
|
|
1139
|
+
`${back}?ok=1&ok_msg=${encodeURIComponent(`已保存 ${result.urls.length} 个上游 URL`)}`,
|
|
1140
|
+
303,
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
return Response.redirect(`${back}?err=${encodeURIComponent(result.msg)}`, 303);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
return json({ error: "not found" }, 404);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const api = url.pathname.match(API_RE);
|
|
1150
|
+
if (api) {
|
|
1151
|
+
const [, owner, repo, numStr, kind] = api;
|
|
1152
|
+
if (!(owner && repo && numStr && kind)) return html(errorPage("404", "bad path"), 404);
|
|
1153
|
+
const number = Number(numStr);
|
|
1154
|
+
try {
|
|
1155
|
+
if (kind === "page") {
|
|
1156
|
+
const page = Math.max(1, Number(url.searchParams.get("page") ?? "1") || 1);
|
|
1157
|
+
const { issue, views, currentPage, hasOlder } = fetchIssuePage(owner, repo, number, page);
|
|
1158
|
+
const payload = {
|
|
1159
|
+
owner,
|
|
1160
|
+
repo,
|
|
1161
|
+
number,
|
|
1162
|
+
issueTitle: issue.title,
|
|
1163
|
+
state: issue.state,
|
|
1164
|
+
totalComments: issue.comment_count,
|
|
1165
|
+
currentPage,
|
|
1166
|
+
hasOlder,
|
|
1167
|
+
comments: views,
|
|
1168
|
+
};
|
|
1169
|
+
return json(payload);
|
|
1170
|
+
}
|
|
1171
|
+
const since = url.searchParams.get("since") ?? new Date(0).toISOString();
|
|
1172
|
+
const views = fetchIssueSince(owner, repo, number, since);
|
|
1173
|
+
return json({ comments: views });
|
|
1174
|
+
} catch (e) {
|
|
1175
|
+
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const isNew = url.pathname.match(REPO_NEW_RE);
|
|
1180
|
+
if (isNew) {
|
|
1181
|
+
const [, owner, repo] = isNew;
|
|
1182
|
+
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
1183
|
+
return html(buildIssueNew(owner, repo, cfg.writesEnabled));
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const view = url.pathname.match(REPO_ISSUE_RE);
|
|
1187
|
+
if (view) {
|
|
1188
|
+
const [, owner, repo, numStr] = view;
|
|
1189
|
+
if (!(owner && repo && numStr)) return html(errorPage("404", "bad path"), 404);
|
|
1190
|
+
const number = Number(numStr);
|
|
1191
|
+
try {
|
|
1192
|
+
const { html: body } = buildIssueThread(cfg, owner, repo, number, ctx.user?.login);
|
|
1193
|
+
return html(body);
|
|
1194
|
+
} catch (e) {
|
|
1195
|
+
const status = e instanceof StoreError ? e.status : 500;
|
|
1196
|
+
return html(errorPage(status === 404 ? "找不到 issue" : "加载失败", errMsg(e)), status);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const list = url.pathname.match(REPO_LIST_RE);
|
|
1201
|
+
if (list) {
|
|
1202
|
+
const [, owner, repo] = list;
|
|
1203
|
+
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
1204
|
+
const state = parseState(url.searchParams.get("state"));
|
|
1205
|
+
const q = url.searchParams.get("q")?.trim() ?? "";
|
|
1206
|
+
try {
|
|
1207
|
+
return html(buildIssueList(owner, repo, state, cfg.writesEnabled, q));
|
|
1208
|
+
} catch (e) {
|
|
1209
|
+
return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const whPage = url.pathname.match(REPO_WEBHOOKS_RE);
|
|
1214
|
+
if (whPage) {
|
|
1215
|
+
const [, owner, repo] = whPage;
|
|
1216
|
+
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
1217
|
+
const project = getProject(owner, repo);
|
|
1218
|
+
if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
|
|
1219
|
+
const isAdmin = canAdminProject(project.id, ctx.user);
|
|
1220
|
+
if (!isAdmin) return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
|
|
1221
|
+
const hooks = listWebhooks(project.id);
|
|
1222
|
+
const deliveriesByWebhook = new Map(
|
|
1223
|
+
hooks.map((h) => [h.id, listDeliveries(h.id, 10)] as const)
|
|
1224
|
+
);
|
|
1225
|
+
return html(
|
|
1226
|
+
buildWebhooksPage({ project, webhooks: hooks, deliveriesByWebhook })
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const membersPage = url.pathname.match(REPO_MEMBERS_RE);
|
|
1231
|
+
if (membersPage) {
|
|
1232
|
+
const [, owner, repo] = membersPage;
|
|
1233
|
+
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
1234
|
+
const project = getProject(owner, repo);
|
|
1235
|
+
if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
|
|
1236
|
+
// Lazy bootstrap: site-admin opening a pre-RBAC project auto-inserts as admin.
|
|
1237
|
+
if (ctx.user!.is_admin === 1) ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
|
|
1238
|
+
if (!canAdminProject(project.id, ctx.user)) {
|
|
1239
|
+
return html(errorPage("无权限", "需要该项目 admin 角色才能管理成员"), 403);
|
|
1240
|
+
}
|
|
1241
|
+
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
1242
|
+
const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
|
|
1243
|
+
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
|
|
1244
|
+
return html(buildProjectMembersPage(ctx.user!, project, flash));
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const upstreamsPage = url.pathname.match(REPO_UPSTREAMS_RE);
|
|
1248
|
+
if (upstreamsPage) {
|
|
1249
|
+
const [, owner, repo] = upstreamsPage;
|
|
1250
|
+
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
1251
|
+
const project = getProject(owner, repo);
|
|
1252
|
+
if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
|
|
1253
|
+
if (!canAdminProject(project.id, ctx.user)) {
|
|
1254
|
+
return html(errorPage("无权限", "需要该项目 admin 角色才能管理上游"), 403);
|
|
1255
|
+
}
|
|
1256
|
+
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
1257
|
+
const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
|
|
1258
|
+
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
|
|
1259
|
+
return html(buildProjectUpstreamsPage(ctx.user!, project, flash));
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const repoMatch = url.pathname.match(REPO_RE);
|
|
1263
|
+
if (repoMatch) {
|
|
1264
|
+
const [, owner, repo] = repoMatch;
|
|
1265
|
+
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
1266
|
+
return Response.redirect(
|
|
1267
|
+
`${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`,
|
|
1268
|
+
302
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
return html(errorPage("404", `未知路径: ${url.pathname}`), 404);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function parseState(s: string | null): "open" | "closed" | "all" {
|
|
1276
|
+
return s === "closed" ? "closed" : s === "all" ? "all" : "open";
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function createProjectSafe(owner: string, name: string) {
|
|
1280
|
+
let project = getProject(owner, name);
|
|
1281
|
+
if (project) return project;
|
|
1282
|
+
// Auto-create project on first issue POST. Owner/name were already validated by the
|
|
1283
|
+
// URL regex shape; allow creation here so `/<owner>/<new-repo>/issues` (POST) bootstraps
|
|
1284
|
+
// a project in one step. Use createIssue's tx for atomicity.
|
|
1285
|
+
project = createProject(owner, name, "");
|
|
1286
|
+
return project;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function errMsg(e: unknown): string {
|
|
1290
|
+
return e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Boot-time: ensure the configured operator user exists. If it's a fresh DB
|
|
1294
|
+
// with no admins, promote it. Covers both fresh installs and the legacy-token
|
|
1295
|
+
// migration path (legacy cookies resolve to this user).
|
|
1296
|
+
// Also ensure the reserved system user exists (kind=system, used to attribute
|
|
1297
|
+
// automated actions like future cron / import jobs; not login-able).
|
|
1298
|
+
(() => {
|
|
1299
|
+
const op = ensureBootstrapAdmin(cfg.operatorLogin);
|
|
1300
|
+
if (op.is_admin === 0 && countAdmins() === 0) {
|
|
1301
|
+
updateUser(op.login, { is_admin: true });
|
|
1302
|
+
log.info("bootstrap: operator promoted to admin", { login: op.login });
|
|
1303
|
+
}
|
|
1304
|
+
const sys = ensureBootstrapSystem(cfg.systemLogin);
|
|
1305
|
+
void sys;
|
|
1306
|
+
})();
|
|
1307
|
+
|
|
1308
|
+
log.info("ework listening", { host: cfg.host, port: cfg.port, writes: cfg.writesEnabled, operator: cfg.operatorLogin });
|
|
1309
|
+
|
|
1310
|
+
export {};
|