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.setHead(repo.name, newBranch).catch(() => {});
836 }
837
838 return redirect(`/${repo.name}/settings?success=Settings+saved.`);
839 },
840 {
841 body: t.Object({
842 description: t.Optional(t.String()),
843 is_private: t.Optional(t.String()),
844 is_pinned: t.Optional(t.String()),
845 allow_user_labels: t.Optional(t.String()),
846 default_branch: t.Optional(t.String()),
847 issue_template: t.Optional(t.String()),
848 patch_template: t.Optional(t.String()),
849 }),
850 },
851 )
852
853 .post("/:repo/settings/delete", async ({ params, cookie }) => {
854 const user = await resolveSession(cookie.session.value);
855 const deny = requireAdmin(user);
856 if (deny) return deny;
857 const repo = await getRepo(params.repo, true);
858 if (!repo) return new Response("Not found", { status: 404 });
859
860 // Remove the on-disk repo first. If this fails (e.g. permission error),
861 // we abort before touching the DB so the repo remains accessible.
862 rmSync(repoPath(repo.name), { recursive: true, force: true });
863 await db.deleteFrom("repositories").where("id", "=", repo.id).execute();
864
865 return new Response(null, { status: 302, headers: { Location: "/" } });
866 })
867
868 .post(
869 "/:repo/settings/labels",
870 async ({ params, body, cookie }) => {
871 const user = await resolveSession(cookie.session.value);
872 const deny = requireAdmin(user);
873 if (deny) return deny;
874 const repo = await getRepo(params.repo, true);
875 if (!repo) return new Response("Not found", { status: 404 });
876
877 const name = body.name?.trim();
878 const color = body.color?.trim();
879
880 if (!name || name.length > MAX_LABEL_NAME_LENGTH) {
881 return redirect(
882 `/${repo.name}/settings?error=${encodeURIComponent("Label name must be 1–50 characters.")}`,
883 );
884 }
885 if (!color || !/^#[0-9a-fA-F]{6}$/.test(color)) {
886 return redirect(
887 `/${repo.name}/settings?error=${encodeURIComponent("Invalid color.")}`,
888 );
889 }
890
891 try {
892 await db
893 .insertInto("labels")
894 .values({
895 repo_id: repo.id,
896 name,
897 color,
898 created_at: new Date().toISOString(),
899 })
900 .execute();
901 } catch {
902 return redirect(
903 `/${repo.name}/settings?error=${encodeURIComponent("A label with that name already exists.")}`,
904 );
905 }
906
907 return redirect(`/${repo.name}/settings?success=Label+created.`);
908 },
909 {
910 body: t.Object({
911 name: t.String(),
912 color: t.String(),
913 }),
914 },
915 )
916
917 .post(
918 "/:repo/settings/labels/delete",
919 async ({ params, body, cookie }) => {
920 const user = await resolveSession(cookie.session.value);
921 const deny = requireAdmin(user);
922 if (deny) return deny;
923 const repo = await getRepo(params.repo, true);
924 if (!repo) return new Response("Not found", { status: 404 });
925
926 const label = await db
927 .selectFrom("labels")
928 .select(["id", "repo_id"])
929 .where("id", "=", body.id)
930 .executeTakeFirst();
931
932 if (!label || label.repo_id !== repo.id) {
933 return redirect(
934 `/${repo.name}/settings?error=${encodeURIComponent("Label not found.")}`,
935 );
936 }
937
938 await db.deleteFrom("labels").where("id", "=", body.id).execute();
939
940 return redirect(`/${repo.name}/settings?success=Label+deleted.`);
941 },
942 {
943 body: t.Object({ id: t.Numeric() }),
944 },
945 )
946
947 // ── Branches ──────────────────────────────────────────────────────────────
948
949 .get("/:repo/branches", async ({ params, query, cookie }) => {
950 const user = await resolveSession(cookie.session.value);
951 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
952 if (!repo) return new Response("Not found", { status: 404 });
953 const allBranches = await git.branchesWithInfo(repo.name);
954 const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
955 const totalPages = Math.max(
956 1,
957 Math.ceil(allBranches.length / BRANCHES_PER_PAGE),
958 );
959 const safePage = Math.min(page, totalPages);
960 const branches = allBranches.slice(
961 (safePage - 1) * BRANCHES_PER_PAGE,
962 safePage * BRANCHES_PER_PAGE,
963 );
964 const success =
965 typeof query.success === "string" ? query.success : undefined;
966 const error = typeof query.error === "string" ? query.error : undefined;
967 return html(
968 <BranchList
969 user={user}
970 repo={repo}
971 branches={branches}
972 page={safePage}
973 totalPages={totalPages}
974 success={success}
975 error={error}
976 />,
977 );
978 })
979
980 .post(
981 "/:repo/branches/create",
982 async ({ params, body, cookie }) => {
983 const user = await resolveSession(cookie.session.value);
984 const deny = requireAdmin(user);
985 if (deny) return deny;
986 const repo = await getRepo(params.repo, true);
987 if (!repo) return new Response("Not found", { status: 404 });
988
989 const name = body.name?.trim() ?? "";
990 const sourceRef = body.source_ref?.trim() ?? "";
991
992 if (
993 !name ||
994 !/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(name) ||
995 name.includes("..") ||
996 name.length > MAX_BRANCH_NAME_LENGTH
997 ) {
998 return redirect(
999 `/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
1000 );
1001 }
1002 if (!sourceRef) {
1003 return redirect(
1004 `/${repo.name}/branches?error=${encodeURIComponent("Source ref is required.")}`,
1005 );
1006 }
1007
1008 const result = await git.createBranch(repo.name, name, sourceRef);
1009 if (result === "ok") {
1010 return redirect(
1011 `/${repo.name}/branches?success=${encodeURIComponent(`Branch "${name}" created.`)}`,
1012 );
1013 }
1014 if (result === "already_exists") {
1015 return redirect(
1016 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${name}" already exists.`)}`,
1017 );
1018 }
1019 if (result === "bad_ref") {
1020 return redirect(
1021 `/${repo.name}/branches?error=${encodeURIComponent(`"${sourceRef}" is not a valid ref.`)}`,
1022 );
1023 }
1024 return redirect(
1025 `/${repo.name}/branches?error=${encodeURIComponent("Failed to create branch.")}`,
1026 );
1027 },
1028 {
1029 body: t.Object({
1030 name: t.String(),
1031 source_ref: t.String(),
1032 }),
1033 },
1034 )
1035
1036 .post(
1037 "/:repo/branches/delete",
1038 async ({ params, body, cookie }) => {
1039 const user = await resolveSession(cookie.session.value);
1040 const deny = requireAdmin(user);
1041 if (deny) return deny;
1042 const repo = await getRepo(params.repo, true);
1043 if (!repo) return new Response("Not found", { status: 404 });
1044
1045 const name = body.name?.trim() ?? "";
1046 if (!name) {
1047 return redirect(
1048 `/${repo.name}/branches?error=${encodeURIComponent("Branch name is required.")}`,
1049 );
1050 }
1051 if (name === repo.default_branch) {
1052 return redirect(
1053 `/${repo.name}/branches?error=${encodeURIComponent("Cannot delete the default branch.")}`,
1054 );
1055 }
1056
1057 const result = await git.deleteBranch(repo.name, name);
1058 if (result === "ok") {
1059 return redirect(
1060 `/${repo.name}/branches?success=${encodeURIComponent(`Branch "${name}" deleted.`)}`,
1061 );
1062 }
1063 if (result === "not_found") {
1064 return redirect(
1065 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${name}" not found.`)}`,
1066 );
1067 }
1068 return redirect(
1069 `/${repo.name}/branches?error=${encodeURIComponent("Failed to delete branch.")}`,
1070 );
1071 },
1072 {
1073 body: t.Object({ name: t.String() }),
1074 },
1075 )
1076
1077 .post(
1078 "/:repo/branches/rename",
1079 async ({ params, body, cookie }) => {
1080 const user = await resolveSession(cookie.session.value);
1081 const deny = requireAdmin(user);
1082 if (deny) return deny;
1083 const repo = await getRepo(params.repo, true);
1084 if (!repo) return new Response("Not found", { status: 404 });
1085
1086 const oldName = body.old_name?.trim() ?? "";
1087 const newName = body.new_name?.trim() ?? "";
1088
1089 if (
1090 !newName ||
1091 !/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(newName) ||
1092 newName.includes("..") ||
1093 newName.length > MAX_BRANCH_NAME_LENGTH
1094 ) {
1095 return redirect(
1096 `/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
1097 );
1098 }
1099
1100 const result = await git.renameBranch(repo.name, oldName, newName);
1101 if (result === "ok") {
1102 // Keep default_branch in DB in sync if we renamed it
1103 if (oldName === repo.default_branch) {
1104 await db
1105 .updateTable("repositories")
1106 .set({ default_branch: newName })
1107 .where("id", "=", repo.id)
1108 .execute();
1109 await git.setHead(repo.name, newName).catch(() => {});
1110 }
1111 return redirect(
1112 `/${repo.name}/branches?success=${encodeURIComponent(`Branch renamed to "${newName}".`)}`,
1113 );
1114 }
1115 if (result === "not_found") {
1116 return redirect(
1117 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${oldName}" not found.`)}`,
1118 );
1119 }
1120 if (result === "already_exists") {
1121 return redirect(
1122 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${newName}" already exists.`)}`,
1123 );
1124 }
1125 return redirect(
1126 `/${repo.name}/branches?error=${encodeURIComponent("Failed to rename branch.")}`,
1127 );
1128 },
1129 {
1130 body: t.Object({
1131 old_name: t.String(),
1132 new_name: t.String(),
1133 }),
1134 },
1135 )
1136
1137 // ── Tags ──────────────────────────────────────────────────────────────────
1138
1139 .get("/:repo/tags", async ({ params, query, cookie }) => {
1140 const user = await resolveSession(cookie.session.value);
1141 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
1142 if (!repo) return new Response("Not found", { status: 404 });
1143 const [allTags, releases] = await Promise.all([
1144 git.tagsWithInfo(repo.name),
1145 db
1146 .selectFrom("releases")
1147 .select(["id", "tag_name"])
1148 .where("repo_id", "=", repo.id)
1149 .where("tag_name", "is not", null)
1150 .execute(),
1151 ]);
1152 const tagReleaseMap = new Map<string, number>();
1153 for (const r of releases) {
1154 if (r.tag_name) tagReleaseMap.set(r.tag_name, r.id);
1155 }
1156 const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
1157 const totalPages = Math.max(
1158 1,
1159 Math.ceil(allTags.length / TAGS_PER_PAGE),
1160 );
1161 const safePage = Math.min(page, totalPages);
1162 const tags = allTags.slice(
1163 (safePage - 1) * TAGS_PER_PAGE,
1164 safePage * TAGS_PER_PAGE,
1165 );
1166 const success =
1167 typeof query.success === "string" ? query.success : undefined;
1168 const error = typeof query.error === "string" ? query.error : undefined;
1169 return html(
1170 <TagList
1171 user={user}
1172 repo={repo}
1173 tags={tags}
1174 tagReleaseMap={tagReleaseMap}
1175 page={safePage}
1176 totalPages={totalPages}
1177 success={success}
1178 error={error}
1179 />,
1180 );
1181 })
1182
1183 .post(
1184 "/:repo/tags/create",
1185 async ({ params, body, cookie }) => {
1186 const user = await resolveSession(cookie.session.value);
1187 const deny = requireAdmin(user);
1188 if (deny) return deny;
1189 const repo = await getRepo(params.repo, true);
1190 if (!repo) return new Response("Not found", { status: 404 });
1191
1192 const tagName = body.name?.trim() ?? "";
1193 const ref = body.ref?.trim() ?? "";
1194 const message = body.message?.trim() || undefined;
1195
1196 if (!tagName || !/^[a-zA-Z0-9._\-+]+$/.test(tagName)) {
1197 return redirect(
1198 `/${repo.name}/tags?error=${encodeURIComponent("Invalid tag name.")}`,
1199 );
1200 }
1201 if (!ref) {
1202 return redirect(
1203 `/${repo.name}/tags?error=${encodeURIComponent("Target ref is required.")}`,
1204 );
1205 }
1206
1207 const result = await git.createTag(
1208 repo.name,
1209 tagName,
1210 ref,
1211 message,
1212 message ? config.COMMITTER_NAME : undefined,
1213 message ? config.COMMITTER_EMAIL : undefined,
1214 );
1215 if (result === "ok") {
1216 return redirect(
1217 `/${repo.name}/tags?success=${encodeURIComponent(`Tag "${tagName}" created.`)}`,
1218 );
1219 }
1220 if (result === "already_exists") {
1221 return redirect(
1222 `/${repo.name}/tags?error=${encodeURIComponent(`Tag "${tagName}" already exists.`)}`,
1223 );
1224 }
1225 if (result === "bad_ref") {
1226 return redirect(
1227 `/${repo.name}/tags?error=${encodeURIComponent(`"${ref}" is not a valid ref.`)}`,
1228 );
1229 }
1230 return redirect(
1231 `/${repo.name}/tags?error=${encodeURIComponent("Failed to create tag.")}`,
1232 );
1233 },
1234 {
1235 body: t.Object({
1236 name: t.String(),
1237 ref: t.String(),
1238 message: t.Optional(t.String()),
1239 }),
1240 },
1241 )
1242
1243 .post(
1244 "/:repo/tags/delete",
1245 async ({ params, body, cookie }) => {
1246 const user = await resolveSession(cookie.session.value);
1247 const deny = requireAdmin(user);
1248 if (deny) return deny;
1249 const repo = await getRepo(params.repo, true);
1250 if (!repo) return new Response("Not found", { status: 404 });
1251
1252 const tagName = body.name?.trim() ?? "";
1253 if (!tagName) {
1254 return redirect(
1255 `/${repo.name}/tags?error=${encodeURIComponent("Tag name is required.")}`,
1256 );
1257 }
1258
1259 const result = await git.deleteTag(repo.name, tagName);
1260 if (result === "ok") {
1261 return redirect(
1262 `/${repo.name}/tags?success=${encodeURIComponent(`Tag "${tagName}" deleted.`)}`,
1263 );
1264 }
1265 if (result === "not_found") {
1266 return redirect(
1267 `/${repo.name}/tags?error=${encodeURIComponent(`Tag "${tagName}" not found.`)}`,
1268 );
1269 }
1270 return redirect(
1271 `/${repo.name}/tags?error=${encodeURIComponent("Failed to delete tag.")}`,
1272 );
1273 },
1274 {
1275 body: t.Object({ name: t.String() }),
1276 },
1277 )
1278
1279 // ── File creation ─────────────────────────────────────────────────────────
1280
1281 .get("/:repo/new-file/:ref", async ({ params, query, cookie }) => {
1282 const user = await resolveSession(cookie.session.value);
1283 const deny = requireAdmin(user);
1284 if (deny) return deny;
1285 const repo = await getRepo(params.repo, true);
1286 if (!repo) return new Response("Not found", { status: 404 });
1287 const dir = typeof query.dir === "string" ? query.dir : "";
1288 const error = typeof query.error === "string" ? query.error : undefined;
1289 return html(
1290 <NewFileForm
1291 user={user!}
1292 repo={repo}
1293 ref={params.ref}
1294 dir={dir}
1295 error={error}
1296 />,
1297 );
1298 })
1299
1300 .post(
1301 "/:repo/new-file/:ref",
1302 async ({ params, body, cookie }) => {
1303 const user = await resolveSession(cookie.session.value);
1304 const deny = requireAdmin(user);
1305 if (deny) return deny;
1306 const repo = await getRepo(params.repo, true);
1307 if (!repo) return new Response("Not found", { status: 404 });
1308
1309 const filePath = body.path?.trim() ?? "";
1310 const content = body.content ?? "";
1311 const message = body.message?.trim() || `Add ${filePath}`;
1312
1313 if (
1314 !filePath ||
1315 filePath.startsWith("/") ||
1316 filePath.includes("..") ||
1317 filePath.includes("\0")
1318 ) {
1319 return redirect(
1320 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Invalid file path.")}`,
1321 );
1322 }
1323
1324 // Ensure we're on a branch
1325 const branches = await git.branches(repo.name);
1326 if (branches.length > 0 && !branches.includes(params.ref)) {
1327 return redirect(
1328 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Can only create files on a branch.")}`,
1329 );
1330 }
1331
1332 try {
1333 const commit = await git.createFile(
1334 repo.name,
1335 params.ref,
1336 filePath,
1337 content,
1338 message,
1339 config.COMMITTER_NAME,
1340 config.COMMITTER_EMAIL,
1341 );
1342 return redirect(`/${repo.name}/commit/${commit}`);
1343 } catch {
1344 return redirect(
1345 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Failed to create file.")}`,
1346 );
1347 }
1348 },
1349 {
1350 body: t.Object({
1351 path: t.String(),
1352 content: t.Optional(t.String()),
1353 message: t.Optional(t.String()),
1354 }),
1355 },
1356 )
1357
1358 // ── File deletion ─────────────────────────────────────────────────────────
1359
1360 .post(
1361 "/:repo/delete-file/:ref/*",
1362 async ({ params, body, cookie }) => {
1363 const user = await resolveSession(cookie.session.value);
1364 const deny = requireAdmin(user);
1365 if (deny) return deny;
1366 const repo = await getRepo(params.repo, true);
1367 if (!repo) return new Response("Not found", { status: 404 });
1368
1369 const filePath = decodeURIComponent(params["*"]);
1370 const message = body.message?.trim() || `Delete ${filePath}`;
1371
1372 try {
1373 const commit = await git.deleteFile(
1374 repo.name,
1375 params.ref,
1376 filePath,
1377 message,
1378 config.COMMITTER_NAME,
1379 config.COMMITTER_EMAIL,
1380 );
1381 return redirect(`/${repo.name}/commit/${commit}`);
1382 } catch {
1383 return redirect(
1384 `/${repo.name}/blob/${params.ref}/${filePath}?error=${encodeURIComponent("Failed to delete file.")}`,
1385 );
1386 }
1387 },
1388 {
1389 body: t.Object({
1390 message: t.Optional(t.String()),
1391 }),
1392 },
1393 );
1394