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