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