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