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 BINARY_DETECT_BYTES,
9 BRANCHES_PER_PAGE,
10 COMMITS_PER_PAGE,
11 MAX_BRANCH_NAME_LENGTH,
12 MAX_LABEL_NAME_LENGTH,
13 paths,
14 REPOS_PER_PAGE,
15 TAGS_PER_PAGE,
16 VALID_REPO_NAME_RE,
17 YEAR_SECONDS,
18} from "../constants.ts";
19import { db } from "../db/index.ts";
20import { redirect } from "../lib/redirect.ts";
21import { requireAdmin, resolveSession } from "../middleware/session.ts";
22import { git, repoPath, type TreeEntry } from "../services/git.ts";
23import {
24 hasBinaryContent,
25 prepareDiff,
26 serveFile,
27} from "../services/highlightWorker.ts";
28import { renderMarkdown } from "../services/markdown.ts";
29import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
30import { html } from "../views/render.tsx";
31import { BranchList } from "../views/repos/BranchList.tsx";
32import { CommitDetail } from "../views/repos/CommitDetail.tsx";
33import { CommitLog } from "../views/repos/CommitLog.tsx";
34import { FileBlob } from "../views/repos/FileBlob.tsx";
35import { FileEdit } from "../views/repos/FileEdit.tsx";
36import { FileTree } from "../views/repos/FileTree.tsx";
37import { NewFileForm } from "../views/repos/NewFileForm.tsx";
38import { NewRepo } from "../views/repos/NewRepo.tsx";
39import { RepoHome } from "../views/repos/RepoHome.tsx";
40import { RepoList } from "../views/repos/RepoList.tsx";
41import { RepoSettings } from "../views/repos/RepoSettings.tsx";
42import { TagList } from "../views/repos/TagList.tsx";
43
44async function getRepo(name: string, isAdmin: boolean) {
45 if (!repoDiskExists(name)) return null;
46 const repo = await ensureRepoRecord(name);
47 if (repo.is_private && !isAdmin) return null;
48 return repo;
49}
50
51async function mimeForContent(
52 filename: string,
53 content: Buffer,
54): Promise<string> {
55 const result = await fileTypeFromBuffer(content);
56 if (result) return result.mime;
57
58 const typeFromName = Bun.file(filename).type;
59 if (typeFromName !== "application/octet-stream") {
60 return typeFromName;
61 }
62
63 return hasBinaryContent(content.subarray(0, BINARY_DETECT_BYTES))
64 ? "application/octet-stream"
65 : "text/plain; charset=utf-8";
66}
67
68const README_NAMES = ["README.md", "readme.md", "README", "readme"];
69
70async function readReadme(
71 repo: string,
72 ref: string,
73 dir = "",
74 knownEntries: TreeEntry[],
75): Promise<{ content: Buffer; filename: string } | null> {
76 const prefix = dir ? `${dir}/` : "";
77 // Fast path: we already have the tree listing — find the README name and
78 // fetch only that one file, avoiding up to 3 wasted git-show calls.
79 const entryNames = new Set(knownEntries.map((e) => e.name));
80 const name = README_NAMES.find((n) => entryNames.has(n));
81 if (!name) return null;
82 const content = await git.show(repo, ref, `${prefix}${name}`);
83 return content ? { content, filename: `${prefix}${name}` } : null;
84}
85
86export const repoRoutes = new Elysia()
87 .get("/allowed_signers", () => {
88 return new Response(readFileSync(paths.ALLOWED_SIGNERS_PATH), {
89 headers: { "Content-Type": "text/plain; charset=utf-8" },
90 });
91 })
92 .guard({
93 cookie: t.Cookie({
94 session: t.Optional(t.String()),
95 repo_sort: t.Optional(t.String()),
96 }),
97 })
98 .post(
99 "/sort",
100 ({ body }) => {
101 const sort = body.sort === "name" ? "name" : "created";
102 return redirect(
103 "/",
104 `repo_sort=${sort}; Path=/; SameSite=Lax; Max-Age=${YEAR_SECONDS}`,
105 );
106 },
107 { body: t.Object({ sort: t.String() }) },
108 )
109 .get(
110 "/",
111 async ({ cookie, query }) => {
112 const user = await resolveSession(cookie.session.value);
113 const search = query.q?.trim() || undefined;
114 const page = Math.max(1, query.page ?? 1);
115 const sort = cookie.repo_sort.value === "name" ? "name" : "created";
116
117 const isAdmin = user?.isAdmin ?? false;
118
119 const searchPattern = search
120 ? `%${search.replace(/[\\%_]/g, "\\$&")}%`
121 : undefined;
122
123 const countResult = await db
124 .selectFrom("repositories")
125 .select(db.fn.countAll<number>().as("count"))
126 .where((eb) =>
127 isAdmin
128 ? eb.or([
129 eb("is_private", "=", 0),
130 eb("is_private", "=", 1),
131 ])
132 : eb("is_private", "=", 0),
133 )
134 .$if(!!searchPattern, (qb) =>
135 qb.where(
136 sql<boolean>`("name" LIKE ${searchPattern} ESCAPE '\\' OR "description" LIKE ${searchPattern} ESCAPE '\\')`,
137 ),
138 )
139 .executeTakeFirst();
140
141 const totalCount = Number(countResult?.count ?? 0);
142 const totalPages = Math.max(
143 1,
144 Math.ceil(totalCount / REPOS_PER_PAGE),
145 );
146 const safePage = Math.min(page, totalPages);
147
148 const repos = await db
149 .selectFrom("repositories")
150 .selectAll()
151 .where((eb) =>
152 isAdmin
153 ? eb.or([
154 eb("is_private", "=", 0),
155 eb("is_private", "=", 1),
156 ])
157 : eb("is_private", "=", 0),
158 )
159 .$if(!!searchPattern, (qb) =>
160 qb.where(
161 sql<boolean>`("name" LIKE ${searchPattern} ESCAPE '\\' OR "description" LIKE ${searchPattern} ESCAPE '\\')`,
162 ),
163 )
164 .orderBy("is_pinned", "desc")
165 .$if(sort === "name", (qb) => qb.orderBy("name", "asc"))
166 .$if(sort === "created", (qb) =>
167 qb.orderBy("created_at", "desc"),
168 )
169 .limit(REPOS_PER_PAGE)
170 .offset((safePage - 1) * REPOS_PER_PAGE)
171 .execute();
172
173 const searchParam = search
174 ? `&q=${encodeURIComponent(search)}`
175 : "";
176 const pagination = {
177 page: safePage,
178 totalPages,
179 pageUrlTemplate: `/?page={page}${searchParam}`,
180 };
181
182 return html(
183 <RepoList
184 user={user}
185 repos={repos}
186 search={search}
187 sort={sort}
188 pagination={pagination}
189 />,
190 );
191 },
192 {
193 query: t.Object({
194 q: t.Optional(t.String()),
195 page: t.Optional(t.Numeric()),
196 }),
197 },
198 )
199
200 .get("/new", async ({ cookie }) => {
201 const user = await resolveSession(cookie.session.value);
202 const deny = requireAdmin(user);
203 if (deny) return deny;
204 return html(<NewRepo user={user!} />);
205 })
206
207 .post(
208 "/new",
209 async ({ body, cookie }) => {
210 const user = await resolveSession(cookie.session.value);
211 const deny = requireAdmin(user);
212 if (deny) return deny;
213
214 const { name, description, is_private, default_branch } = body;
215
216 if (!VALID_REPO_NAME_RE.test(name)) {
217 return html(
218 <NewRepo user={user!} error="Invalid repository name" />,
219 );
220 }
221
222 const branch = (default_branch?.trim() || "main").replace(
223 /[^a-zA-Z0-9._/-]/g,
224 "",
225 );
226
227 const existing = await db
228 .selectFrom("repositories")
229 .select("id")
230 .where("name", "=", name)
231 .executeTakeFirst();
232 if (existing) {
233 return html(
234 <NewRepo
235 user={user!}
236 error="Repository name already taken"
237 />,
238 );
239 }
240
241 const now = new Date().toISOString();
242 await db
243 .insertInto("repositories")
244 .values({
245 name,
246 description: description || null,
247 is_private: is_private === "1" ? 1 : 0,
248 default_branch: branch,
249 created_at: now,
250 })
251 .execute();
252
253 // Initialise the git repo after the DB record is committed. If
254 // git.init fails we roll back the DB record so the two stay in sync.
255 try {
256 await git.init(name, branch);
257 } catch (err) {
258 await db
259 .deleteFrom("repositories")
260 .where("name", "=", name)
261 .execute();
262 throw err;
263 }
264 return new Response(null, {
265 status: 302,
266 headers: { Location: `/${name}` },
267 });
268 },
269 {
270 body: t.Object({
271 name: t.String(),
272 description: t.Optional(t.String()),
273 is_private: t.Optional(t.String()),
274 default_branch: t.Optional(t.String()),
275 }),
276 },
277 )
278
279 .get("/:repo", async ({ params, cookie }) => {
280 const user = await resolveSession(cookie.session.value);
281 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
282 if (!repo) return new Response("Not found", { status: 404 });
283
284 const hasContent = await git.hasCommits(repo.name);
285 let readmeHtml: string | null = null;
286 let readmePath: string | undefined;
287 let entries: Awaited<ReturnType<typeof git.lsTree>> = [];
288 let branches: string[] = [];
289 let tags: string[] = [];
290
291 if (hasContent) {
292 const [lsResult, branchResult, tagResult, resolved] =
293 await Promise.all([
294 git.lsTree(repo.name, repo.default_branch),
295 git.branches(repo.name),
296 git.tags(repo.name),
297 git.resolveRef(repo.name, repo.default_branch),
298 ]);
299 entries = lsResult;
300 branches = branchResult;
301 tags = tagResult;
302 const readme = await readReadme(
303 repo.name,
304 repo.default_branch,
305 "",
306 lsResult,
307 );
308 if (readme) {
309 const key = resolved
310 ? `readme:${repo.name}:${resolved}:`
311 : undefined;
312 readmeHtml = renderMarkdown(
313 readme.content.toString("utf-8"),
314 key,
315 {
316 repo: repo.name,
317 ref: repo.default_branch,
318 dir: "",
319 },
320 );
321 readmePath = readme.filename;
322 }
323 }
324
325 return html(
326 <RepoHome
327 user={user}
328 repo={repo}
329 entries={entries}
330 readmeHtml={readmeHtml}
331 readmePath={readmePath}
332 hasContent={hasContent}
333 branches={branches}
334 tags={tags}
335 />,
336 );
337 })
338
339 .get(
340 "/:repo/branch-switch",
341 async ({ params, query, cookie }) => {
342 const user = await resolveSession(cookie.session.value);
343 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
344 if (!repo) return new Response("Not found", { status: 404 });
345
346 const ref = query.rev?.trim();
347 if (!ref)
348 return new Response(null, {
349 status: 302,
350 headers: { Location: `/${repo.name}` },
351 });
352
353 const view = query.view;
354 const subpath = query.path ?? "";
355
356 if (view === "commits") {
357 return new Response(null, {
358 status: 302,
359 headers: { Location: `/${repo.name}/commits/${ref}` },
360 });
361 }
362 if (view === "blob" && subpath) {
363 return new Response(null, {
364 status: 302,
365 headers: {
366 Location: `/${repo.name}/blob/${ref}/${subpath}`,
367 },
368 });
369 }
370 const location = subpath
371 ? `/${repo.name}/tree/${ref}/${subpath}`
372 : `/${repo.name}/tree/${ref}`;
373 return new Response(null, {
374 status: 302,
375 headers: { Location: location },
376 });
377 },
378 {
379 query: t.Object({
380 rev: t.Optional(t.String()),
381 view: t.Optional(t.String()),
382 path: t.Optional(t.String()),
383 }),
384 },
385 )
386
387 .get("/:repo/tree/:ref", async ({ params, cookie }) => {
388 const user = await resolveSession(cookie.session.value);
389 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
390 if (!repo) return new Response("Not found", { status: 404 });
391
392 const resolved = await git.resolveRef(repo.name, params.ref);
393 if (!resolved) return new Response("Not found", { status: 404 });
394
395 const [entries, branches, tags] = await Promise.all([
396 git.lsTree(repo.name, params.ref),
397 git.branches(repo.name),
398 git.tags(repo.name),
399 ]);
400 const readme = await readReadme(repo.name, params.ref, "", entries);
401 const readmeHtml = readme
402 ? renderMarkdown(
403 readme.content.toString("utf-8"),
404 `readme:${repo.name}:${resolved}:`,
405 { repo: repo.name, ref: params.ref, dir: "" },
406 )
407 : null;
408 return html(
409 <FileTree
410 user={user}
411 repo={repo}
412 ref={params.ref}
413 subpath=""
414 entries={entries}
415 branches={branches}
416 tags={tags}
417 readmeHtml={readmeHtml}
418 readmePath={readme?.filename}
419 />,
420 );
421 })
422
423 .get("/:repo/tree/:ref/*", async ({ params, cookie }) => {
424 const user = await resolveSession(cookie.session.value);
425 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
426 if (!repo) return new Response("Not found", { status: 404 });
427
428 const resolved = await git.resolveRef(repo.name, params.ref);
429 if (!resolved) return new Response("Not found", { status: 404 });
430
431 const subpath = decodeURIComponent(params["*"]);
432 const [entries, branches, tags] = await Promise.all([
433 git.lsTree(repo.name, params.ref, subpath),
434 git.branches(repo.name),
435 git.tags(repo.name),
436 ]);
437 if (entries.length === 0) {
438 // Could be a file — redirect to blob
439 return new Response(null, {
440 status: 302,
441 headers: {
442 Location: `/${repo.name}/blob/${params.ref}/${subpath}`,
443 },
444 });
445 }
446 const readme = await readReadme(
447 repo.name,
448 params.ref,
449 subpath,
450 entries,
451 );
452 const readmeHtml = readme
453 ? renderMarkdown(
454 readme.content.toString("utf-8"),
455 `readme:${repo.name}:${resolved}:${subpath}`,
456 { repo: repo.name, ref: params.ref, dir: subpath },
457 )
458 : null;
459 return html(
460 <FileTree
461 user={user}
462 repo={repo}
463 ref={params.ref}
464 subpath={subpath}
465 entries={entries}
466 branches={branches}
467 tags={tags}
468 readmeHtml={readmeHtml}
469 readmePath={readme?.filename}
470 />,
471 );
472 })
473
474 .get("/:repo/blob/:ref/*", async ({ params, cookie }) => {
475 const user = await resolveSession(cookie.session.value);
476 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
477 if (!repo) return new Response("Not found", { status: 404 });
478
479 const filePath = decodeURIComponent(params["*"]);
480 const [content, branches, tags, commitSHA] = await Promise.all([
481 git.show(repo.name, params.ref, filePath),
482 git.branches(repo.name),
483 git.tags(repo.name),
484 git.resolveRef(repo.name, params.ref),
485 ]);
486 if (!content || !commitSHA)
487 return new Response("Not found", { status: 404 });
488
489 const filename = path.basename(filePath);
490 const [view, markdownHtml] = await Promise.all([
491 serveFile(
492 content,
493 filename,
494 `${repo.name}:${commitSHA}:${filePath}`,
495 ),
496 /\.mdx?$/i.test(filename)
497 ? Promise.resolve(
498 renderMarkdown(
499 content.toString("utf-8"),
500 `${repo.name}:${commitSHA}:${filePath}`,
501 {
502 repo: repo.name,
503 ref: params.ref,
504 dir:
505 path.dirname(filePath) === "."
506 ? ""
507 : path.dirname(filePath),
508 },
509 ),
510 )
511 : Promise.resolve(undefined),
512 ]);
513 return html(
514 <FileBlob
515 user={user}
516 repo={repo}
517 ref={params.ref}
518 filePath={filePath}
519 view={view}
520 branches={branches}
521 tags={tags}
522 markdownHtml={markdownHtml}
523 />,
524 );
525 })
526
527 .get("/:repo/raw/:ref/*", async ({ params, cookie, request }) => {
528 const user = await resolveSession(cookie.session.value);
529 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
530 if (!repo) return new Response("Not found", { status: 404 });
531
532 const filePath = decodeURIComponent(params["*"]);
533 const content = await git.show(repo.name, params.ref, filePath);
534 if (!content) return new Response("Not found", { status: 404 });
535
536 const filename = path.basename(filePath);
537 const contentType = await mimeForContent(filename, content);
538 const total = content.length;
539
540 const rangeHeader = request.headers.get("Range");
541 if (rangeHeader) {
542 const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
543 if (match) {
544 const start = match[1] ? parseInt(match[1], 10) : 0;
545 const end = match[2] ? parseInt(match[2], 10) : total - 1;
546 const clampedEnd = Math.min(end, total - 1);
547 return new Response(content.subarray(start, clampedEnd + 1), {
548 status: 206,
549 headers: {
550 "Content-Type": contentType,
551 "Content-Range": `bytes ${start}-${clampedEnd}/${total}`,
552 "Accept-Ranges": "bytes",
553 "Content-Length": String(clampedEnd - start + 1),
554 },
555 });
556 }
557 }
558
559 return new Response(content, {
560 headers: {
561 "Content-Type": contentType,
562 "Content-Disposition": `inline; filename="${filename}"`,
563 "Content-Length": String(total),
564 "Accept-Ranges": "bytes",
565 },
566 });
567 })
568
569 .get("/:repo/edit/:ref/*", async ({ params, query, cookie }) => {
570 const user = await resolveSession(cookie.session.value);
571 const deny = requireAdmin(user);
572 if (deny) return deny;
573 const repo = await getRepo(params.repo, true);
574 if (!repo) return new Response("Not found", { status: 404 });
575
576 const filePath = decodeURIComponent(params["*"]);
577 const branches = await git.branches(repo.name);
578 if (!branches.includes(params.ref))
579 return new Response("Not found", { status: 404 });
580
581 const content = await git.show(repo.name, params.ref, filePath);
582 if (!content) return new Response("Not found", { status: 404 });
583
584 if (hasBinaryContent(content.subarray(0, 8000)))
585 return new Response("Not found", { status: 404 });
586
587 const queryError =
588 typeof query.error === "string" ? query.error : undefined;
589 return html(
590 <FileEdit
591 user={user!}
592 repo={repo}
593 ref={params.ref}
594 filePath={filePath}
595 content={content.toString("utf-8")}
596 queryError={queryError}
597 />,
598 );
599 })
600
601 .post(
602 "/:repo/edit/:ref/*",
603 async ({ params, body, cookie }) => {
604 const user = await resolveSession(cookie.session.value);
605 const deny = requireAdmin(user);
606 if (deny) return deny;
607 const repo = await getRepo(params.repo, true);
608 if (!repo) return new Response("Not found", { status: 404 });
609
610 const filePath = decodeURIComponent(params["*"]);
611 const branches = await git.branches(repo.name);
612 if (!branches.includes(params.ref))
613 return new Response("Not found", { status: 404 });
614
615 const newPath = body.new_path?.trim() || undefined;
616 const targetPath =
617 newPath && newPath !== filePath ? newPath : filePath;
618
619 if (
620 newPath &&
621 newPath !== filePath &&
622 (newPath.startsWith("/") ||
623 newPath.includes("..") ||
624 newPath.includes("\0"))
625 ) {
626 return redirect(
627 `/${repo.name}/edit/${params.ref}/${filePath}?error=${encodeURIComponent("Invalid file path.")}`,
628 );
629 }
630
631 const defaultMessage =
632 targetPath !== filePath
633 ? `Rename ${path.basename(filePath)} to ${path.basename(targetPath)}`
634 : `Edited ${path.basename(filePath)}`;
635 const message = body.message?.trim() || defaultMessage;
636 const content = (body.content ?? "").replaceAll("\r\n", "\n");
637
638 const commit = await git.editFile(
639 repo.name,
640 params.ref,
641 filePath,
642 content,
643 message,
644 config.COMMITTER_NAME,
645 config.COMMITTER_EMAIL,
646 newPath,
647 );
648
649 return new Response(null, {
650 status: 302,
651 headers: {
652 Location: `/${repo.name}/commit/${commit}`,
653 },
654 });
655 },
656 {
657 body: t.Object({
658 content: t.Optional(t.String()),
659 message: t.Optional(t.String()),
660 new_path: t.Optional(t.String()),
661 }),
662 },
663 )
664
665 .get(
666 "/:repo/commits/:ref",
667 async ({ params, cookie, query }) => {
668 const user = await resolveSession(cookie.session.value);
669 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
670 if (!repo) return new Response("Not found", { status: 404 });
671
672 // Cursor-based pagination — O(1) regardless of history depth.
673 // `after` = SHA of the last commit on the previous page (resume cursor).
674 // `prev` = the `after` value used on the page that linked here, so we can
675 // reconstruct a "← Newer" link without a full history traversal.
676 const after = query.after?.trim() || null;
677 const prev = query.prev?.trim() || null;
678
679 const [rawCommits, branches, tags] = await Promise.all([
680 // When `after` is set: start at that SHA and skip it (--skip=1 is O(1)),
681 // then fetch LIMIT+1 to detect whether another page exists.
682 after
683 ? git.log(repo.name, after, COMMITS_PER_PAGE + 1, 1)
684 : git.log(repo.name, params.ref, COMMITS_PER_PAGE + 1, 0),
685 git.branches(repo.name),
686 git.tags(repo.name),
687 ]);
688
689 const hasNext = rawCommits.length > COMMITS_PER_PAGE;
690 const commits = rawCommits.slice(0, COMMITS_PER_PAGE);
691
692 // Build cursor URLs.
693 // "Older" advances past the last commit on this page.
694 // "Newer" goes back one page using the `prev` cursor saved in the URL,
695 // or to the first page if we're on page 2.
696 const base = `/${repo.name}/commits/${params.ref}`;
697 const olderUrl = hasNext
698 ? `${base}?after=${commits[commits.length - 1]?.hash}&prev=${after ?? ""}`
699 : null;
700 const newerUrl = after
701 ? prev
702 ? `${base}?after=${prev}`
703 : base
704 : null;
705
706 return html(
707 <CommitLog
708 user={user}
709 repo={repo}
710 ref={params.ref}
711 commits={commits}
712 branches={branches}
713 tags={tags}
714 olderUrl={olderUrl}
715 newerUrl={newerUrl}
716 />,
717 );
718 },
719 {
720 query: t.Object({
721 after: t.Optional(t.String()),
722 prev: t.Optional(t.String()),
723 }),
724 },
725 )
726
727 .get("/:repo/commit/:sha", async ({ params, cookie }) => {
728 const user = await resolveSession(cookie.session.value);
729 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
730 if (!repo) return new Response("Not found", { status: 404 });
731
732 const [meta, rawDiff] = await Promise.all([
733 git.commitMeta(repo.name, params.sha),
734 git.diff(repo.name, params.sha),
735 ]);
736 if (!meta) return new Response("Commit not found", { status: 404 });
737 const files = await prepareDiff(
738 rawDiff,
739 `commit:${repo.name}:${params.sha}`,
740 repo.name,
741 );
742 return html(
743 <CommitDetail
744 user={user}
745 repo={repo}
746 sha={params.sha}
747 meta={meta}
748 files={files}
749 />,
750 );
751 })
752
753 .get("/:repo/settings", async ({ params, query, cookie }) => {
754 const user = await resolveSession(cookie.session.value);
755 const deny = requireAdmin(user);
756 if (deny) return deny;
757 const repo = await getRepo(params.repo, true);
758 if (!repo) return new Response("Not found", { status: 404 });
759 const branches = await git.branches(repo.name);
760 const [labels, secrets] = await Promise.all([
761 db
762 .selectFrom("labels")
763 .selectAll()
764 .where("repo_id", "=", repo.id)
765 .orderBy("name", "asc")
766 .execute(),
767 db
768 .selectFrom("ci_secrets")
769 .select(["id", "name", "description", "created_at"])
770 .where("repo_id", "=", repo.id)
771 .orderBy("name", "asc")
772 .execute(),
773 ]);
774 const success =
775 typeof query.success === "string" ? query.success : undefined;
776 const error = typeof query.error === "string" ? query.error : undefined;
777 return html(
778 <RepoSettings
779 user={user!}
780 repo={repo}
781 branches={branches}
782 labels={labels}
783 secrets={secrets}
784 success={success}
785 error={error}
786 />,
787 );
788 })
789
790 .post(
791 "/:repo/settings",
792 async ({ params, body, 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 const {
800 description,
801 is_private,
802 is_pinned,
803 allow_user_labels,
804 default_branch,
805 issue_template,
806 patch_template,
807 } = body;
808
809 const branches = await git.branches(repo.name);
810 const newBranch = default_branch?.trim() || repo.default_branch;
811
812 // Validate the selected branch exists (only if repo has commits)
813 if (branches.length > 0 && !branches.includes(newBranch)) {
814 return redirect(
815 `/${repo.name}/settings?error=${encodeURIComponent(`Branch "${newBranch}" does not exist.`)}`,
816 );
817 }
818
819 await db
820 .updateTable("repositories")
821 .set({
822 description: description?.trim() || null,
823 is_private: is_private === "1" ? 1 : 0,
824 is_pinned: is_pinned === "1" ? 1 : 0,
825 allow_user_labels: allow_user_labels === "1" ? 1 : 0,
826 default_branch: newBranch,
827 issue_template: issue_template?.trim() || null,
828 patch_template: patch_template?.trim() || null,
829 })
830 .where("id", "=", repo.id)
831 .execute();
832
833 // Keep git HEAD in sync if the branch actually exists
834 if (branches.includes(newBranch)) {
835 await git
836 .setHead(repo.name, newBranch)
837 .catch((e) =>
838 console.error(
839 `setHead failed for ${repo.name}/${newBranch}:`,
840 e,
841 ),
842 );
843 }
844
845 return redirect(`/${repo.name}/settings?success=Settings+saved.`);
846 },
847 {
848 body: t.Object({
849 description: t.Optional(t.String()),
850 is_private: t.Optional(t.String()),
851 is_pinned: t.Optional(t.String()),
852 allow_user_labels: t.Optional(t.String()),
853 default_branch: t.Optional(t.String()),
854 issue_template: t.Optional(t.String()),
855 patch_template: t.Optional(t.String()),
856 }),
857 },
858 )
859
860 .post("/:repo/settings/delete", async ({ params, cookie }) => {
861 const user = await resolveSession(cookie.session.value);
862 const deny = requireAdmin(user);
863 if (deny) return deny;
864 const repo = await getRepo(params.repo, true);
865 if (!repo) return new Response("Not found", { status: 404 });
866
867 // Remove the on-disk repo first. If this fails (e.g. permission error),
868 // we abort before touching the DB so the repo remains accessible.
869 rmSync(repoPath(repo.name), { recursive: true, force: true });
870 await db.deleteFrom("repositories").where("id", "=", repo.id).execute();
871
872 return new Response(null, { status: 302, headers: { Location: "/" } });
873 })
874
875 .post(
876 "/:repo/settings/labels",
877 async ({ params, body, cookie }) => {
878 const user = await resolveSession(cookie.session.value);
879 const deny = requireAdmin(user);
880 if (deny) return deny;
881 const repo = await getRepo(params.repo, true);
882 if (!repo) return new Response("Not found", { status: 404 });
883
884 const name = body.name?.trim();
885 const color = body.color?.trim();
886
887 if (!name || name.length > MAX_LABEL_NAME_LENGTH) {
888 return redirect(
889 `/${repo.name}/settings?error=${encodeURIComponent("Label name must be 1–50 characters.")}`,
890 );
891 }
892 if (!color || !/^#[0-9a-fA-F]{6}$/.test(color)) {
893 return redirect(
894 `/${repo.name}/settings?error=${encodeURIComponent("Invalid color.")}`,
895 );
896 }
897
898 try {
899 await db
900 .insertInto("labels")
901 .values({
902 repo_id: repo.id,
903 name,
904 color,
905 created_at: new Date().toISOString(),
906 })
907 .execute();
908 } catch {
909 return redirect(
910 `/${repo.name}/settings?error=${encodeURIComponent("A label with that name already exists.")}`,
911 );
912 }
913
914 return redirect(`/${repo.name}/settings?success=Label+created.`);
915 },
916 {
917 body: t.Object({
918 name: t.String(),
919 color: t.String(),
920 }),
921 },
922 )
923
924 .post(
925 "/:repo/settings/labels/delete",
926 async ({ params, body, cookie }) => {
927 const user = await resolveSession(cookie.session.value);
928 const deny = requireAdmin(user);
929 if (deny) return deny;
930 const repo = await getRepo(params.repo, true);
931 if (!repo) return new Response("Not found", { status: 404 });
932
933 const label = await db
934 .selectFrom("labels")
935 .select(["id", "repo_id"])
936 .where("id", "=", body.id)
937 .executeTakeFirst();
938
939 if (!label || label.repo_id !== repo.id) {
940 return redirect(
941 `/${repo.name}/settings?error=${encodeURIComponent("Label not found.")}`,
942 );
943 }
944
945 await db.deleteFrom("labels").where("id", "=", body.id).execute();
946
947 return redirect(`/${repo.name}/settings?success=Label+deleted.`);
948 },
949 {
950 body: t.Object({ id: t.Numeric() }),
951 },
952 )
953
954 // ── Branches ──────────────────────────────────────────────────────────────
955
956 .get("/:repo/branches", async ({ params, query, cookie }) => {
957 const user = await resolveSession(cookie.session.value);
958 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
959 if (!repo) return new Response("Not found", { status: 404 });
960 const allBranches = await git.branchesWithInfo(repo.name);
961 const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
962 const totalPages = Math.max(
963 1,
964 Math.ceil(allBranches.length / BRANCHES_PER_PAGE),
965 );
966 const safePage = Math.min(page, totalPages);
967 const branches = allBranches.slice(
968 (safePage - 1) * BRANCHES_PER_PAGE,
969 safePage * BRANCHES_PER_PAGE,
970 );
971 const success =
972 typeof query.success === "string" ? query.success : undefined;
973 const error = typeof query.error === "string" ? query.error : undefined;
974 return html(
975 <BranchList
976 user={user}
977 repo={repo}
978 branches={branches}
979 page={safePage}
980 totalPages={totalPages}
981 success={success}
982 error={error}
983 />,
984 );
985 })
986
987 .post(
988 "/:repo/branches/create",
989 async ({ params, body, cookie }) => {
990 const user = await resolveSession(cookie.session.value);
991 const deny = requireAdmin(user);
992 if (deny) return deny;
993 const repo = await getRepo(params.repo, true);
994 if (!repo) return new Response("Not found", { status: 404 });
995
996 const name = body.name?.trim() ?? "";
997 const sourceRef = body.source_ref?.trim() ?? "";
998
999 if (
1000 !name ||
1001 !/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(name) ||
1002 name.includes("..") ||
1003 name.length > MAX_BRANCH_NAME_LENGTH
1004 ) {
1005 return redirect(
1006 `/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
1007 );
1008 }
1009 if (!sourceRef) {
1010 return redirect(
1011 `/${repo.name}/branches?error=${encodeURIComponent("Source ref is required.")}`,
1012 );
1013 }
1014
1015 const result = await git.createBranch(repo.name, name, sourceRef);
1016 if (result === "ok") {
1017 return redirect(
1018 `/${repo.name}/branches?success=${encodeURIComponent(`Branch "${name}" created.`)}`,
1019 );
1020 }
1021 if (result === "already_exists") {
1022 return redirect(
1023 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${name}" already exists.`)}`,
1024 );
1025 }
1026 if (result === "bad_ref") {
1027 return redirect(
1028 `/${repo.name}/branches?error=${encodeURIComponent(`"${sourceRef}" is not a valid ref.`)}`,
1029 );
1030 }
1031 return redirect(
1032 `/${repo.name}/branches?error=${encodeURIComponent("Failed to create branch.")}`,
1033 );
1034 },
1035 {
1036 body: t.Object({
1037 name: t.String(),
1038 source_ref: t.String(),
1039 }),
1040 },
1041 )
1042
1043 .post(
1044 "/:repo/branches/delete",
1045 async ({ params, body, cookie }) => {
1046 const user = await resolveSession(cookie.session.value);
1047 const deny = requireAdmin(user);
1048 if (deny) return deny;
1049 const repo = await getRepo(params.repo, true);
1050 if (!repo) return new Response("Not found", { status: 404 });
1051
1052 const name = body.name?.trim() ?? "";
1053 if (!name) {
1054 return redirect(
1055 `/${repo.name}/branches?error=${encodeURIComponent("Branch name is required.")}`,
1056 );
1057 }
1058 if (name === repo.default_branch) {
1059 return redirect(
1060 `/${repo.name}/branches?error=${encodeURIComponent("Cannot delete the default branch.")}`,
1061 );
1062 }
1063
1064 const result = await git.deleteBranch(repo.name, name);
1065 if (result === "ok") {
1066 return redirect(
1067 `/${repo.name}/branches?success=${encodeURIComponent(`Branch "${name}" deleted.`)}`,
1068 );
1069 }
1070 if (result === "not_found") {
1071 return redirect(
1072 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${name}" not found.`)}`,
1073 );
1074 }
1075 return redirect(
1076 `/${repo.name}/branches?error=${encodeURIComponent("Failed to delete branch.")}`,
1077 );
1078 },
1079 {
1080 body: t.Object({ name: t.String() }),
1081 },
1082 )
1083
1084 .post(
1085 "/:repo/branches/rename",
1086 async ({ params, body, cookie }) => {
1087 const user = await resolveSession(cookie.session.value);
1088 const deny = requireAdmin(user);
1089 if (deny) return deny;
1090 const repo = await getRepo(params.repo, true);
1091 if (!repo) return new Response("Not found", { status: 404 });
1092
1093 const oldName = body.old_name?.trim() ?? "";
1094 const newName = body.new_name?.trim() ?? "";
1095
1096 if (
1097 !newName ||
1098 !/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(newName) ||
1099 newName.includes("..") ||
1100 newName.length > MAX_BRANCH_NAME_LENGTH
1101 ) {
1102 return redirect(
1103 `/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
1104 );
1105 }
1106
1107 const result = await git.renameBranch(repo.name, oldName, newName);
1108 if (result === "ok") {
1109 // Keep default_branch in DB in sync if we renamed it
1110 if (oldName === repo.default_branch) {
1111 await db
1112 .updateTable("repositories")
1113 .set({ default_branch: newName })
1114 .where("id", "=", repo.id)
1115 .execute();
1116 await git
1117 .setHead(repo.name, newName)
1118 .catch((e) =>
1119 console.error(
1120 `setHead failed for ${repo.name}/${newName}:`,
1121 e,
1122 ),
1123 );
1124 }
1125 return redirect(
1126 `/${repo.name}/branches?success=${encodeURIComponent(`Branch renamed to "${newName}".`)}`,
1127 );
1128 }
1129 if (result === "not_found") {
1130 return redirect(
1131 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${oldName}" not found.`)}`,
1132 );
1133 }
1134 if (result === "already_exists") {
1135 return redirect(
1136 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${newName}" already exists.`)}`,
1137 );
1138 }
1139 return redirect(
1140 `/${repo.name}/branches?error=${encodeURIComponent("Failed to rename branch.")}`,
1141 );
1142 },
1143 {
1144 body: t.Object({
1145 old_name: t.String(),
1146 new_name: t.String(),
1147 }),
1148 },
1149 )
1150
1151 // ── Tags ──────────────────────────────────────────────────────────────────
1152
1153 .get("/:repo/tags", async ({ params, query, cookie }) => {
1154 const user = await resolveSession(cookie.session.value);
1155 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
1156 if (!repo) return new Response("Not found", { status: 404 });
1157 const [allTags, releases] = await Promise.all([
1158 git.tagsWithInfo(repo.name),
1159 db
1160 .selectFrom("releases")
1161 .select(["id", "tag_name"])
1162 .where("repo_id", "=", repo.id)
1163 .where("tag_name", "is not", null)
1164 .execute(),
1165 ]);
1166 const tagReleaseMap = new Map<string, number>();
1167 for (const r of releases) {
1168 if (r.tag_name) tagReleaseMap.set(r.tag_name, r.id);
1169 }
1170 const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
1171 const totalPages = Math.max(
1172 1,
1173 Math.ceil(allTags.length / TAGS_PER_PAGE),
1174 );
1175 const safePage = Math.min(page, totalPages);
1176 const tags = allTags.slice(
1177 (safePage - 1) * TAGS_PER_PAGE,
1178 safePage * TAGS_PER_PAGE,
1179 );
1180 const success =
1181 typeof query.success === "string" ? query.success : undefined;
1182 const error = typeof query.error === "string" ? query.error : undefined;
1183 return html(
1184 <TagList
1185 user={user}
1186 repo={repo}
1187 tags={tags}
1188 tagReleaseMap={tagReleaseMap}
1189 page={safePage}
1190 totalPages={totalPages}
1191 success={success}
1192 error={error}
1193 />,
1194 );
1195 })
1196
1197 .post(
1198 "/:repo/tags/create",
1199 async ({ params, body, cookie }) => {
1200 const user = await resolveSession(cookie.session.value);
1201 const deny = requireAdmin(user);
1202 if (deny) return deny;
1203 const repo = await getRepo(params.repo, true);
1204 if (!repo) return new Response("Not found", { status: 404 });
1205
1206 const tagName = body.name?.trim() ?? "";
1207 const ref = body.ref?.trim() ?? "";
1208 const message = body.message?.trim() || undefined;
1209
1210 if (!tagName || !/^[a-zA-Z0-9._\-+]+$/.test(tagName)) {
1211 return redirect(
1212 `/${repo.name}/tags?error=${encodeURIComponent("Invalid tag name.")}`,
1213 );
1214 }
1215 if (!ref) {
1216 return redirect(
1217 `/${repo.name}/tags?error=${encodeURIComponent("Target ref is required.")}`,
1218 );
1219 }
1220
1221 const result = await git.createTag(
1222 repo.name,
1223 tagName,
1224 ref,
1225 message,
1226 message ? config.COMMITTER_NAME : undefined,
1227 message ? config.COMMITTER_EMAIL : undefined,
1228 );
1229 if (result === "ok") {
1230 return redirect(
1231 `/${repo.name}/tags?success=${encodeURIComponent(`Tag "${tagName}" created.`)}`,
1232 );
1233 }
1234 if (result === "already_exists") {
1235 return redirect(
1236 `/${repo.name}/tags?error=${encodeURIComponent(`Tag "${tagName}" already exists.`)}`,
1237 );
1238 }
1239 if (result === "bad_ref") {
1240 return redirect(
1241 `/${repo.name}/tags?error=${encodeURIComponent(`"${ref}" is not a valid ref.`)}`,
1242 );
1243 }
1244 return redirect(
1245 `/${repo.name}/tags?error=${encodeURIComponent("Failed to create tag.")}`,
1246 );
1247 },
1248 {
1249 body: t.Object({
1250 name: t.String(),
1251 ref: t.String(),
1252 message: t.Optional(t.String()),
1253 }),
1254 },
1255 )
1256
1257 .post(
1258 "/:repo/tags/delete",
1259 async ({ params, body, cookie }) => {
1260 const user = await resolveSession(cookie.session.value);
1261 const deny = requireAdmin(user);
1262 if (deny) return deny;
1263 const repo = await getRepo(params.repo, true);
1264 if (!repo) return new Response("Not found", { status: 404 });
1265
1266 const tagName = body.name?.trim() ?? "";
1267 if (!tagName) {
1268 return redirect(
1269 `/${repo.name}/tags?error=${encodeURIComponent("Tag name is required.")}`,
1270 );
1271 }
1272
1273 const result = await git.deleteTag(repo.name, tagName);
1274 if (result === "ok") {
1275 return redirect(
1276 `/${repo.name}/tags?success=${encodeURIComponent(`Tag "${tagName}" deleted.`)}`,
1277 );
1278 }
1279 if (result === "not_found") {
1280 return redirect(
1281 `/${repo.name}/tags?error=${encodeURIComponent(`Tag "${tagName}" not found.`)}`,
1282 );
1283 }
1284 return redirect(
1285 `/${repo.name}/tags?error=${encodeURIComponent("Failed to delete tag.")}`,
1286 );
1287 },
1288 {
1289 body: t.Object({ name: t.String() }),
1290 },
1291 )
1292
1293 // ── File creation ─────────────────────────────────────────────────────────
1294
1295 .get("/:repo/new-file/:ref", async ({ params, query, cookie }) => {
1296 const user = await resolveSession(cookie.session.value);
1297 const deny = requireAdmin(user);
1298 if (deny) return deny;
1299 const repo = await getRepo(params.repo, true);
1300 if (!repo) return new Response("Not found", { status: 404 });
1301 const dir = typeof query.dir === "string" ? query.dir : "";
1302 const error = typeof query.error === "string" ? query.error : undefined;
1303 return html(
1304 <NewFileForm
1305 user={user!}
1306 repo={repo}
1307 ref={params.ref}
1308 dir={dir}
1309 error={error}
1310 />,
1311 );
1312 })
1313
1314 .post(
1315 "/:repo/new-file/:ref",
1316 async ({ params, body, cookie }) => {
1317 const user = await resolveSession(cookie.session.value);
1318 const deny = requireAdmin(user);
1319 if (deny) return deny;
1320 const repo = await getRepo(params.repo, true);
1321 if (!repo) return new Response("Not found", { status: 404 });
1322
1323 const filePath = body.path?.trim() ?? "";
1324 const content = body.content ?? "";
1325 const message = body.message?.trim() || `Add ${filePath}`;
1326
1327 if (
1328 !filePath ||
1329 filePath.startsWith("/") ||
1330 filePath.includes("..") ||
1331 filePath.includes("\0")
1332 ) {
1333 return redirect(
1334 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Invalid file path.")}`,
1335 );
1336 }
1337
1338 // Ensure we're on a branch
1339 const branches = await git.branches(repo.name);
1340 if (branches.length > 0 && !branches.includes(params.ref)) {
1341 return redirect(
1342 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Can only create files on a branch.")}`,
1343 );
1344 }
1345
1346 try {
1347 const commit = await git.createFile(
1348 repo.name,
1349 params.ref,
1350 filePath,
1351 content,
1352 message,
1353 config.COMMITTER_NAME,
1354 config.COMMITTER_EMAIL,
1355 );
1356 return redirect(`/${repo.name}/commit/${commit}`);
1357 } catch {
1358 return redirect(
1359 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Failed to create file.")}`,
1360 );
1361 }
1362 },
1363 {
1364 body: t.Object({
1365 path: t.String(),
1366 content: t.Optional(t.String()),
1367 message: t.Optional(t.String()),
1368 }),
1369 },
1370 )
1371
1372 // ── File deletion ─────────────────────────────────────────────────────────
1373
1374 .post(
1375 "/:repo/delete-file/:ref/*",
1376 async ({ params, body, cookie }) => {
1377 const user = await resolveSession(cookie.session.value);
1378 const deny = requireAdmin(user);
1379 if (deny) return deny;
1380 const repo = await getRepo(params.repo, true);
1381 if (!repo) return new Response("Not found", { status: 404 });
1382
1383 const filePath = decodeURIComponent(params["*"]);
1384 const message = body.message?.trim() || `Delete ${filePath}`;
1385
1386 try {
1387 const commit = await git.deleteFile(
1388 repo.name,
1389 params.ref,
1390 filePath,
1391 message,
1392 config.COMMITTER_NAME,
1393 config.COMMITTER_EMAIL,
1394 );
1395 return redirect(`/${repo.name}/commit/${commit}`);
1396 } catch {
1397 return redirect(
1398 `/${repo.name}/blob/${params.ref}/${filePath}?error=${encodeURIComponent("Failed to delete file.")}`,
1399 );
1400 }
1401 },
1402 {
1403 body: t.Object({
1404 message: t.Optional(t.String()),
1405 }),
1406 },
1407 );
1408