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