repos.tsx
Raw
1import { readFileSync, rmSync } from "node:fs";
2import path from "node:path";
3import { Elysia, t } from "elysia";
4import { fileTypeFromBuffer } from "file-type";
5import { sql } from "kysely";
6import config from "../config.ts";
7import {
8 COMMITS_PER_PAGE,
9 paths,
10 REPOS_PER_PAGE,
11 VALID_REPO_NAME_RE,
12} from "../constants.ts";
13import { db } from "../db/index.ts";
14import { redirect } from "../lib/redirect.ts";
15import { requireAdmin, resolveSession } from "../middleware/session.ts";
16import { git, repoPath } from "../services/git.ts";
17import {
18 hasBinaryContent,
19 prepareDiff,
20 serveFile,
21} from "../services/highlightWorker.ts";
22import { renderMarkdown } from "../services/markdown.ts";
23import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
24import { html } from "../views/render.tsx";
25import { CommitDetail } from "../views/repos/CommitDetail.tsx";
26import { CommitLog } from "../views/repos/CommitLog.tsx";
27import { FileBlob } from "../views/repos/FileBlob.tsx";
28import { FileEdit } from "../views/repos/FileEdit.tsx";
29import { FileTree } from "../views/repos/FileTree.tsx";
30import { NewRepo } from "../views/repos/NewRepo.tsx";
31import { RepoHome } from "../views/repos/RepoHome.tsx";
32import { RepoList } from "../views/repos/RepoList.tsx";
33import { RepoSettings } from "../views/repos/RepoSettings.tsx";
34
35async function getRepo(name: string, isAdmin: boolean) {
36 if (!repoDiskExists(name)) return null;
37 const repo = await ensureRepoRecord(name);
38 if (repo.is_private && !isAdmin) return null;
39 return repo;
40}
41
42async function mimeForContent(
43 filename: string,
44 content: Buffer,
45): Promise<string> {
46 const result = await fileTypeFromBuffer(content);
47 if (result) return result.mime;
48
49 const typeFromName = Bun.file(filename).type;
50 if (typeFromName !== "application/octet-stream") {
51 return typeFromName;
52 }
53
54 return hasBinaryContent(content.subarray(0, 8000))
55 ? "application/octet-stream"
56 : "text/plain; charset=utf-8";
57}
58
59async function readReadme(
60 repo: string,
61 ref: string,
62 dir = "",
63): Promise<{ content: Buffer; filename: string } | null> {
64 const prefix = dir ? `${dir}/` : "";
65 const names = ["README.md", "readme.md", "README", "readme"];
66 const results = await Promise.all(
67 names.map((n) => git.show(repo, ref, `${prefix}${n}`)),
68 );
69 for (let i = 0; i < results.length; i++) {
70 if (results[i]) {
71 return {
72 content: results[i] as Buffer,
73 filename: `${prefix}${names[i]}`,
74 };
75 }
76 }
77 return null;
78}
79
80export const repoRoutes = new Elysia()
81 .get("/allowed_signers", () => {
82 return new Response(readFileSync(paths.ALLOWED_SIGNERS_PATH), {
83 headers: { "Content-Type": "text/plain; charset=utf-8" },
84 });
85 })
86 .guard({
87 cookie: t.Cookie({
88 session: t.Optional(t.String()),
89 repo_sort: t.Optional(t.String()),
90 }),
91 })
92 .post(
93 "/sort",
94 ({ body }) => {
95 const sort = body.sort === "name" ? "name" : "created";
96 return redirect(
97 "/",
98 `repo_sort=${sort}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`,
99 );
100 },
101 { body: t.Object({ sort: t.String() }) },
102 )
103 .get(
104 "/",
105 async ({ cookie, query }) => {
106 const user = await resolveSession(cookie.session.value);
107 const search = query.q?.trim() || undefined;
108 const page = Math.max(1, query.page ?? 1);
109 const sort = cookie.repo_sort.value === "name" ? "name" : "created";
110
111 const isAdmin = user?.isAdmin ?? false;
112
113 const searchPattern = search
114 ? `%${search.replace(/[\\%_]/g, "\\$&")}%`
115 : undefined;
116
117 const countResult = await db
118 .selectFrom("repositories")
119 .select(db.fn.countAll<number>().as("count"))
120 .where((eb) =>
121 isAdmin
122 ? eb.or([
123 eb("is_private", "=", 0),
124 eb("is_private", "=", 1),
125 ])
126 : eb("is_private", "=", 0),
127 )
128 .$if(!!searchPattern, (qb) =>
129 qb.where(
130 sql<boolean>`("name" LIKE ${searchPattern} ESCAPE '\\' OR "description" LIKE ${searchPattern} ESCAPE '\\')`,
131 ),
132 )
133 .executeTakeFirst();
134
135 const totalCount = Number(countResult?.count ?? 0);
136 const totalPages = Math.max(
137 1,
138 Math.ceil(totalCount / REPOS_PER_PAGE),
139 );
140 const safePage = Math.min(page, totalPages);
141
142 const repos = await db
143 .selectFrom("repositories")
144 .selectAll()
145 .where((eb) =>
146 isAdmin
147 ? eb.or([
148 eb("is_private", "=", 0),
149 eb("is_private", "=", 1),
150 ])
151 : eb("is_private", "=", 0),
152 )
153 .$if(!!searchPattern, (qb) =>
154 qb.where(
155 sql<boolean>`("name" LIKE ${searchPattern} ESCAPE '\\' OR "description" LIKE ${searchPattern} ESCAPE '\\')`,
156 ),
157 )
158 .orderBy("is_pinned", "desc")
159 .$if(sort === "name", (qb) => qb.orderBy("name", "asc"))
160 .$if(sort === "created", (qb) =>
161 qb.orderBy("created_at", "desc"),
162 )
163 .limit(REPOS_PER_PAGE)
164 .offset((safePage - 1) * REPOS_PER_PAGE)
165 .execute();
166
167 const searchParam = search
168 ? `&q=${encodeURIComponent(search)}`
169 : "";
170 const pagination = {
171 page: safePage,
172 totalPages,
173 pageUrlTemplate: `/?page={page}${searchParam}`,
174 };
175
176 return html(
177 <RepoList
178 user={user}
179 repos={repos}
180 search={search}
181 sort={sort}
182 pagination={pagination}
183 />,
184 );
185 },
186 {
187 query: t.Object({
188 q: t.Optional(t.String()),
189 page: t.Optional(t.Numeric()),
190 }),
191 },
192 )
193
194 .get("/new", async ({ cookie }) => {
195 const user = await resolveSession(cookie.session.value);
196 const deny = requireAdmin(user);
197 if (deny) return deny;
198 return html(<NewRepo user={user!} />);
199 })
200
201 .post(
202 "/new",
203 async ({ body, cookie }) => {
204 const user = await resolveSession(cookie.session.value);
205 const deny = requireAdmin(user);
206 if (deny) return deny;
207
208 const { name, description, is_private, default_branch } = body;
209
210 if (!VALID_REPO_NAME_RE.test(name)) {
211 return html(
212 <NewRepo user={user!} error="Invalid repository name" />,
213 );
214 }
215
216 const branch = (default_branch?.trim() || "main").replace(
217 /[^a-zA-Z0-9._/-]/g,
218 "",
219 );
220
221 const existing = await db
222 .selectFrom("repositories")
223 .select("id")
224 .where("name", "=", name)
225 .executeTakeFirst();
226 if (existing) {
227 return html(
228 <NewRepo
229 user={user!}
230 error="Repository name already taken"
231 />,
232 );
233 }
234
235 const now = new Date().toISOString();
236 await db
237 .insertInto("repositories")
238 .values({
239 name,
240 description: description || null,
241 is_private: is_private === "1" ? 1 : 0,
242 default_branch: branch,
243 created_at: now,
244 })
245 .execute();
246
247 // Initialise the git repo after the DB record is committed. If
248 // git.init fails we roll back the DB record so the two stay in sync.
249 try {
250 await git.init(name, branch);
251 } catch (err) {
252 await db
253 .deleteFrom("repositories")
254 .where("name", "=", name)
255 .execute();
256 throw err;
257 }
258 return new Response(null, {
259 status: 302,
260 headers: { Location: `/${name}` },
261 });
262 },
263 {
264 body: t.Object({
265 name: t.String(),
266 description: t.Optional(t.String()),
267 is_private: t.Optional(t.String()),
268 default_branch: t.Optional(t.String()),
269 }),
270 },
271 )
272
273 .get("/:repo", async ({ params, cookie }) => {
274 const user = await resolveSession(cookie.session.value);
275 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
276 if (!repo) return new Response("Not found", { status: 404 });
277
278 const hasContent = await git.hasCommits(repo.name);
279 let readmeHtml: string | null = null;
280 let readmePath: string | undefined;
281 let entries: Awaited<ReturnType<typeof git.lsTree>> = [];
282 let branches: string[] = [];
283
284 if (hasContent) {
285 const [lsResult, branchResult, resolved] = await Promise.all([
286 git.lsTree(repo.name, repo.default_branch),
287 git.branches(repo.name),
288 git.resolveRef(repo.name, repo.default_branch),
289 ]);
290 entries = lsResult;
291 branches = branchResult;
292 const readme = await readReadme(repo.name, repo.default_branch);
293 if (readme) {
294 const key = resolved
295 ? `readme:${repo.name}:${resolved}:`
296 : undefined;
297 readmeHtml = renderMarkdown(
298 readme.content.toString("utf-8"),
299 key,
300 {
301 repo: repo.name,
302 ref: repo.default_branch,
303 dir: "",
304 },
305 );
306 readmePath = readme.filename;
307 }
308 }
309
310 return html(
311 <RepoHome
312 user={user}
313 repo={repo}
314 entries={entries}
315 readmeHtml={readmeHtml}
316 readmePath={readmePath}
317 hasContent={hasContent}
318 branches={branches}
319 />,
320 );
321 })
322
323 .get(
324 "/:repo/branch-switch",
325 async ({ params, query, cookie }) => {
326 const user = await resolveSession(cookie.session.value);
327 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
328 if (!repo) return new Response("Not found", { status: 404 });
329
330 const ref = query.rev?.trim();
331 if (!ref)
332 return new Response(null, {
333 status: 302,
334 headers: { Location: `/${repo.name}` },
335 });
336
337 const view = query.view;
338 const subpath = query.path ?? "";
339
340 if (view === "commits") {
341 return new Response(null, {
342 status: 302,
343 headers: { Location: `/${repo.name}/commits/${ref}` },
344 });
345 }
346 if (view === "blob" && subpath) {
347 return new Response(null, {
348 status: 302,
349 headers: {
350 Location: `/${repo.name}/blob/${ref}/${subpath}`,
351 },
352 });
353 }
354 const location = subpath
355 ? `/${repo.name}/tree/${ref}/${subpath}`
356 : `/${repo.name}/tree/${ref}`;
357 return new Response(null, {
358 status: 302,
359 headers: { Location: location },
360 });
361 },
362 {
363 query: t.Object({
364 rev: t.Optional(t.String()),
365 view: t.Optional(t.String()),
366 path: t.Optional(t.String()),
367 }),
368 },
369 )
370
371 .get("/:repo/tree/:ref", async ({ params, cookie }) => {
372 const user = await resolveSession(cookie.session.value);
373 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
374 if (!repo) return new Response("Not found", { status: 404 });
375
376 const resolved = await git.resolveRef(repo.name, params.ref);
377 if (!resolved) return new Response("Not found", { status: 404 });
378
379 const [entries, branches] = await Promise.all([
380 git.lsTree(repo.name, params.ref),
381 git.branches(repo.name),
382 ]);
383 const readme = await readReadme(repo.name, params.ref);
384 const readmeHtml = readme
385 ? renderMarkdown(
386 readme.content.toString("utf-8"),
387 `readme:${repo.name}:${resolved}:`,
388 { repo: repo.name, ref: params.ref, dir: "" },
389 )
390 : null;
391 return html(
392 <FileTree
393 user={user}
394 repo={repo}
395 ref={params.ref}
396 subpath=""
397 entries={entries}
398 branches={branches}
399 readmeHtml={readmeHtml}
400 readmePath={readme?.filename}
401 />,
402 );
403 })
404
405 .get("/:repo/tree/:ref/*", async ({ params, cookie }) => {
406 const user = await resolveSession(cookie.session.value);
407 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
408 if (!repo) return new Response("Not found", { status: 404 });
409
410 const resolved = await git.resolveRef(repo.name, params.ref);
411 if (!resolved) return new Response("Not found", { status: 404 });
412
413 const subpath = decodeURIComponent(params["*"]);
414 const [entries, branches] = await Promise.all([
415 git.lsTree(repo.name, params.ref, subpath),
416 git.branches(repo.name),
417 ]);
418 if (entries.length === 0) {
419 // Could be a file — redirect to blob
420 return new Response(null, {
421 status: 302,
422 headers: {
423 Location: `/${repo.name}/blob/${params.ref}/${subpath}`,
424 },
425 });
426 }
427 const readme = await readReadme(repo.name, params.ref, subpath);
428 const readmeHtml = readme
429 ? renderMarkdown(
430 readme.content.toString("utf-8"),
431 `readme:${repo.name}:${resolved}:${subpath}`,
432 { repo: repo.name, ref: params.ref, dir: subpath },
433 )
434 : null;
435 return html(
436 <FileTree
437 user={user}
438 repo={repo}
439 ref={params.ref}
440 subpath={subpath}
441 entries={entries}
442 branches={branches}
443 readmeHtml={readmeHtml}
444 readmePath={readme?.filename}
445 />,
446 );
447 })
448
449 .get("/:repo/blob/:ref/*", async ({ params, cookie }) => {
450 const user = await resolveSession(cookie.session.value);
451 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
452 if (!repo) return new Response("Not found", { status: 404 });
453
454 const filePath = decodeURIComponent(params["*"]);
455 const [content, branches, commitSHA] = await Promise.all([
456 git.show(repo.name, params.ref, filePath),
457 git.branches(repo.name),
458 git.resolveRef(repo.name, params.ref),
459 ]);
460 if (!content || !commitSHA)
461 return new Response("Not found", { status: 404 });
462
463 const filename = path.basename(filePath);
464 const [view, markdownHtml] = await Promise.all([
465 serveFile(
466 content,
467 filename,
468 `${repo.name}:${commitSHA}:${filePath}`,
469 ),
470 /\.mdx?$/i.test(filename)
471 ? Promise.resolve(
472 renderMarkdown(
473 content.toString("utf-8"),
474 `${repo.name}:${commitSHA}:${filePath}`,
475 {
476 repo: repo.name,
477 ref: params.ref,
478 dir:
479 path.dirname(filePath) === "."
480 ? ""
481 : path.dirname(filePath),
482 },
483 ),
484 )
485 : Promise.resolve(undefined),
486 ]);
487 return html(
488 <FileBlob
489 user={user}
490 repo={repo}
491 ref={params.ref}
492 filePath={filePath}
493 view={view}
494 branches={branches}
495 markdownHtml={markdownHtml}
496 />,
497 );
498 })
499
500 .get("/:repo/raw/:ref/*", async ({ params, cookie, request }) => {
501 const user = await resolveSession(cookie.session.value);
502 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
503 if (!repo) return new Response("Not found", { status: 404 });
504
505 const filePath = decodeURIComponent(params["*"]);
506 const content = await git.show(repo.name, params.ref, filePath);
507 if (!content) return new Response("Not found", { status: 404 });
508
509 const filename = path.basename(filePath);
510 const contentType = await mimeForContent(filename, content);
511 const total = content.length;
512
513 const rangeHeader = request.headers.get("Range");
514 if (rangeHeader) {
515 const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
516 if (match) {
517 const start = match[1] ? parseInt(match[1], 10) : 0;
518 const end = match[2] ? parseInt(match[2], 10) : total - 1;
519 const clampedEnd = Math.min(end, total - 1);
520 return new Response(content.subarray(start, clampedEnd + 1), {
521 status: 206,
522 headers: {
523 "Content-Type": contentType,
524 "Content-Range": `bytes ${start}-${clampedEnd}/${total}`,
525 "Accept-Ranges": "bytes",
526 "Content-Length": String(clampedEnd - start + 1),
527 },
528 });
529 }
530 }
531
532 return new Response(content, {
533 headers: {
534 "Content-Type": contentType,
535 "Content-Disposition": `inline; filename="${filename}"`,
536 "Content-Length": String(total),
537 "Accept-Ranges": "bytes",
538 },
539 });
540 })
541
542 .get("/:repo/edit/:ref/*", async ({ params, cookie }) => {
543 const user = await resolveSession(cookie.session.value);
544 const deny = requireAdmin(user);
545 if (deny) return deny;
546 const repo = await getRepo(params.repo, true);
547 if (!repo) return new Response("Not found", { status: 404 });
548
549 const filePath = decodeURIComponent(params["*"]);
550 const branches = await git.branches(repo.name);
551 if (!branches.includes(params.ref))
552 return new Response("Not found", { status: 404 });
553
554 const content = await git.show(repo.name, params.ref, filePath);
555 if (!content) return new Response("Not found", { status: 404 });
556
557 if (hasBinaryContent(content.subarray(0, 8000)))
558 return new Response("Not found", { status: 404 });
559
560 return html(
561 <FileEdit
562 user={user!}
563 repo={repo}
564 ref={params.ref}
565 filePath={filePath}
566 content={content.toString("utf-8")}
567 />,
568 );
569 })
570
571 .post(
572 "/:repo/edit/:ref/*",
573 async ({ params, body, cookie }) => {
574 const user = await resolveSession(cookie.session.value);
575 const deny = requireAdmin(user);
576 if (deny) return deny;
577 const repo = await getRepo(params.repo, true);
578 if (!repo) return new Response("Not found", { status: 404 });
579
580 const filePath = decodeURIComponent(params["*"]);
581 const branches = await git.branches(repo.name);
582 if (!branches.includes(params.ref))
583 return new Response("Not found", { status: 404 });
584
585 const message =
586 body.message?.trim() || `Edited ${path.basename(filePath)}`;
587 const content = (body.content ?? "").replaceAll("\r\n", "\n");
588
589 const commit = await git.editFile(
590 repo.name,
591 params.ref,
592 filePath,
593 content,
594 message,
595 config.COMMITTER_NAME,
596 config.COMMITTER_EMAIL,
597 );
598
599 return new Response(null, {
600 status: 302,
601 headers: {
602 Location: `/${repo.name}/commit/${commit}`,
603 },
604 });
605 },
606 {
607 body: t.Object({
608 content: t.Optional(t.String()),
609 message: t.Optional(t.String()),
610 }),
611 },
612 )
613
614 .get(
615 "/:repo/commits/:ref",
616 async ({ params, cookie, query }) => {
617 const user = await resolveSession(cookie.session.value);
618 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
619 if (!repo) return new Response("Not found", { status: 404 });
620
621 // Cursor-based pagination — O(1) regardless of history depth.
622 // `after` = SHA of the last commit on the previous page (resume cursor).
623 // `prev` = the `after` value used on the page that linked here, so we can
624 // reconstruct a "← Newer" link without a full history traversal.
625 const after = query.after?.trim() || null;
626 const prev = query.prev?.trim() || null;
627
628 const [rawCommits, branches] = await Promise.all([
629 // When `after` is set: start at that SHA and skip it (--skip=1 is O(1)),
630 // then fetch LIMIT+1 to detect whether another page exists.
631 after
632 ? git.log(repo.name, after, COMMITS_PER_PAGE + 1, 1)
633 : git.log(repo.name, params.ref, COMMITS_PER_PAGE + 1, 0),
634 git.branches(repo.name),
635 ]);
636
637 const hasNext = rawCommits.length > COMMITS_PER_PAGE;
638 const commits = rawCommits.slice(0, COMMITS_PER_PAGE);
639
640 // Build cursor URLs.
641 // "Older" advances past the last commit on this page.
642 // "Newer" goes back one page using the `prev` cursor saved in the URL,
643 // or to the first page if we're on page 2.
644 const base = `/${repo.name}/commits/${params.ref}`;
645 const olderUrl = hasNext
646 ? `${base}?after=${commits[commits.length - 1]?.hash}&prev=${after ?? ""}`
647 : null;
648 const newerUrl = after
649 ? prev
650 ? `${base}?after=${prev}`
651 : base
652 : null;
653
654 return html(
655 <CommitLog
656 user={user}
657 repo={repo}
658 ref={params.ref}
659 commits={commits}
660 branches={branches}
661 olderUrl={olderUrl}
662 newerUrl={newerUrl}
663 />,
664 );
665 },
666 {
667 query: t.Object({
668 after: t.Optional(t.String()),
669 prev: t.Optional(t.String()),
670 }),
671 },
672 )
673
674 .get("/:repo/commit/:sha", async ({ params, cookie }) => {
675 const user = await resolveSession(cookie.session.value);
676 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
677 if (!repo) return new Response("Not found", { status: 404 });
678
679 const [meta, rawDiff] = await Promise.all([
680 git.commitMeta(repo.name, params.sha),
681 git.diff(repo.name, params.sha),
682 ]);
683 if (!meta) return new Response("Commit not found", { status: 404 });
684 const files = await prepareDiff(
685 rawDiff,
686 `commit:${repo.name}:${params.sha}`,
687 repo.name,
688 );
689 return html(
690 <CommitDetail
691 user={user}
692 repo={repo}
693 sha={params.sha}
694 meta={meta}
695 files={files}
696 />,
697 );
698 })
699
700 .get("/:repo/settings", async ({ params, query, cookie }) => {
701 const user = await resolveSession(cookie.session.value);
702 const deny = requireAdmin(user);
703 if (deny) return deny;
704 const repo = await getRepo(params.repo, true);
705 if (!repo) return new Response("Not found", { status: 404 });
706 const branches = await git.branches(repo.name);
707 const labels = await db
708 .selectFrom("labels")
709 .selectAll()
710 .where("repo_id", "=", repo.id)
711 .orderBy("name", "asc")
712 .execute();
713 const success =
714 typeof query.success === "string" ? query.success : undefined;
715 const error = typeof query.error === "string" ? query.error : undefined;
716 return html(
717 <RepoSettings
718 user={user!}
719 repo={repo}
720 branches={branches}
721 labels={labels}
722 success={success}
723 error={error}
724 />,
725 );
726 })
727
728 .post(
729 "/:repo/settings",
730 async ({ params, body, cookie }) => {
731 const user = await resolveSession(cookie.session.value);
732 const deny = requireAdmin(user);
733 if (deny) return deny;
734 const repo = await getRepo(params.repo, true);
735 if (!repo) return new Response("Not found", { status: 404 });
736
737 const {
738 description,
739 is_private,
740 is_pinned,
741 allow_user_labels,
742 default_branch,
743 issue_template,
744 patch_template,
745 } = body;
746
747 const branches = await git.branches(repo.name);
748 const newBranch = default_branch?.trim() || repo.default_branch;
749
750 // Validate the selected branch exists (only if repo has commits)
751 if (branches.length > 0 && !branches.includes(newBranch)) {
752 return redirect(
753 `/${repo.name}/settings?error=${encodeURIComponent(`Branch "${newBranch}" does not exist.`)}`,
754 );
755 }
756
757 await db
758 .updateTable("repositories")
759 .set({
760 description: description?.trim() || null,
761 is_private: is_private === "1" ? 1 : 0,
762 is_pinned: is_pinned === "1" ? 1 : 0,
763 allow_user_labels: allow_user_labels === "1" ? 1 : 0,
764 default_branch: newBranch,
765 issue_template: issue_template?.trim() || null,
766 patch_template: patch_template?.trim() || null,
767 })
768 .where("id", "=", repo.id)
769 .execute();
770
771 // Keep git HEAD in sync if the branch actually exists
772 if (branches.includes(newBranch)) {
773 await git.setHead(repo.name, newBranch).catch(() => {});
774 }
775
776 return redirect(`/${repo.name}/settings?success=Settings+saved.`);
777 },
778 {
779 body: t.Object({
780 description: t.Optional(t.String()),
781 is_private: t.Optional(t.String()),
782 is_pinned: t.Optional(t.String()),
783 allow_user_labels: t.Optional(t.String()),
784 default_branch: t.Optional(t.String()),
785 issue_template: t.Optional(t.String()),
786 patch_template: t.Optional(t.String()),
787 }),
788 },
789 )
790
791 .post("/:repo/settings/delete", async ({ params, cookie }) => {
792 const user = await resolveSession(cookie.session.value);
793 const deny = requireAdmin(user);
794 if (deny) return deny;
795 const repo = await getRepo(params.repo, true);
796 if (!repo) return new Response("Not found", { status: 404 });
797
798 // Remove the on-disk repo first. If this fails (e.g. permission error),
799 // we abort before touching the DB so the repo remains accessible.
800 rmSync(repoPath(repo.name), { recursive: true, force: true });
801 await db.deleteFrom("repositories").where("id", "=", repo.id).execute();
802
803 return new Response(null, { status: 302, headers: { Location: "/" } });
804 })
805
806 .post(
807 "/:repo/settings/labels",
808 async ({ params, body, cookie }) => {
809 const user = await resolveSession(cookie.session.value);
810 const deny = requireAdmin(user);
811 if (deny) return deny;
812 const repo = await getRepo(params.repo, true);
813 if (!repo) return new Response("Not found", { status: 404 });
814
815 const name = body.name?.trim();
816 const color = body.color?.trim();
817
818 if (!name || name.length > 50) {
819 return redirect(
820 `/${repo.name}/settings?error=${encodeURIComponent("Label name must be 1–50 characters.")}`,
821 );
822 }
823 if (!color || !/^#[0-9a-fA-F]{6}$/.test(color)) {
824 return redirect(
825 `/${repo.name}/settings?error=${encodeURIComponent("Invalid color.")}`,
826 );
827 }
828
829 try {
830 await db
831 .insertInto("labels")
832 .values({
833 repo_id: repo.id,
834 name,
835 color,
836 created_at: new Date().toISOString(),
837 })
838 .execute();
839 } catch {
840 return redirect(
841 `/${repo.name}/settings?error=${encodeURIComponent("A label with that name already exists.")}`,
842 );
843 }
844
845 return redirect(`/${repo.name}/settings?success=Label+created.`);
846 },
847 {
848 body: t.Object({
849 name: t.String(),
850 color: t.String(),
851 }),
852 },
853 )
854
855 .post(
856 "/:repo/settings/labels/delete",
857 async ({ params, body, cookie }) => {
858 const user = await resolveSession(cookie.session.value);
859 const deny = requireAdmin(user);
860 if (deny) return deny;
861 const repo = await getRepo(params.repo, true);
862 if (!repo) return new Response("Not found", { status: 404 });
863
864 const label = await db
865 .selectFrom("labels")
866 .select(["id", "repo_id"])
867 .where("id", "=", body.id)
868 .executeTakeFirst();
869
870 if (!label || label.repo_id !== repo.id) {
871 return redirect(
872 `/${repo.name}/settings?error=${encodeURIComponent("Label not found.")}`,
873 );
874 }
875
876 await db.deleteFrom("labels").where("id", "=", body.id).execute();
877
878 return redirect(`/${repo.name}/settings?success=Label+deleted.`);
879 },
880 {
881 body: t.Object({ id: t.Numeric() }),
882 },
883 );
884