repos.tsx
⎇
Raw
1import { existsSync, readFileSync, renameSync, 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 { contentDisposition } from "../lib/contentDisposition.ts";
21import { redirect } from "../lib/redirect.ts";
22import { requireAdmin, resolveSession } from "../middleware/session.ts";
23import { purgeRepoCaches } from "../services/ci.ts";
24import {
25 git,
26 invalidateRefCache,
27 repoPath,
28 type TreeEntry,
29} from "../services/git.ts";
30import { hasBinaryContent } from "../services/highlight.ts";
31import { prepareDiff, serveFile } from "../services/highlightWorker.ts";
32import { renderMarkdown } from "../services/markdown.ts";
33import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
34import { html } from "../views/render.tsx";
35import { BranchList } from "../views/repos/BranchList.tsx";
36import { CommitDetail } from "../views/repos/CommitDetail.tsx";
37import { CommitLog } from "../views/repos/CommitLog.tsx";
38import { FileBlob } from "../views/repos/FileBlob.tsx";
39import { FileEdit } from "../views/repos/FileEdit.tsx";
40import { FileTree } from "../views/repos/FileTree.tsx";
41import { NewFileForm } from "../views/repos/NewFileForm.tsx";
42import { NewRepo } from "../views/repos/NewRepo.tsx";
43import { RepoHome } from "../views/repos/RepoHome.tsx";
44import { RepoList } from "../views/repos/RepoList.tsx";
45import { RepoSettings } from "../views/repos/RepoSettings.tsx";
46import { TagList } from "../views/repos/TagList.tsx";
47
48async function getRepo(name: string, isAdmin: boolean) {
49 if (!repoDiskExists(name)) return null;
50 const repo = await ensureRepoRecord(name);
51 if (repo.is_private && !isAdmin) return null;
52 return repo;
53}
54
55/**
56 * Read a byte range out of a streaming source without ever holding
57 * the full content in memory. Used by the /raw endpoint to honour
58 * HTTP Range headers against `git show`'s pipe.
59 */
60function sliceStream(
61 source: ReadableStream<Uint8Array>,
62 start: number,
63 length: number,
64 onDone: () => void,
65): ReadableStream<Uint8Array> {
66 const reader = source.getReader();
67 let skipped = 0;
68 let emitted = 0;
69 let finished = false;
70 const finish = () => {
71 if (finished) return;
72 finished = true;
73 reader.cancel().catch(() => {});
74 onDone();
75 };
76 return new ReadableStream<Uint8Array>({
77 async pull(controller) {
78 while (emitted < length) {
79 const { value, done } = await reader.read();
80 if (done) {
81 controller.close();
82 finish();
83 return;
84 }
85 let chunk = value;
86 if (skipped < start) {
87 const drop = Math.min(start - skipped, chunk.length);
88 skipped += drop;
89 chunk = chunk.subarray(drop);
90 if (chunk.length === 0) continue;
91 }
92 const remaining = length - emitted;
93 if (chunk.length > remaining)
94 chunk = chunk.subarray(0, remaining);
95 emitted += chunk.length;
96 controller.enqueue(chunk);
97 if (emitted >= length) {
98 controller.close();
99 finish();
100 }
101 return;
102 }
103 controller.close();
104 finish();
105 },
106 cancel() {
107 finish();
108 },
109 });
110}
111
112/** Wrap a process stdout stream so the underlying process is killed on
113 * close or cancel. Without this, a client disconnect partway through a
114 * large blob leaves `git cat-file` running until its pipe back-pressures. */
115function streamWithKill(
116 source: ReadableStream<Uint8Array>,
117 proc: { kill: () => void },
118): ReadableStream<Uint8Array> {
119 const reader = source.getReader();
120 let killed = false;
121 const finish = () => {
122 if (killed) return;
123 killed = true;
124 reader.cancel().catch(() => {});
125 proc.kill();
126 };
127 return new ReadableStream<Uint8Array>({
128 async pull(controller) {
129 try {
130 const { value, done } = await reader.read();
131 if (done) {
132 controller.close();
133 finish();
134 return;
135 }
136 controller.enqueue(value);
137 } catch (err) {
138 controller.error(err);
139 finish();
140 }
141 },
142 cancel() {
143 finish();
144 },
145 });
146}
147
148async function mimeForContent(
149 filename: string,
150 content: Buffer,
151): Promise<string> {
152 const result = await fileTypeFromBuffer(content);
153 if (result) return result.mime;
154
155 const typeFromName = Bun.file(filename).type;
156 if (typeFromName !== "application/octet-stream") {
157 return typeFromName;
158 }
159
160 return hasBinaryContent(content.subarray(0, BINARY_DETECT_BYTES))
161 ? "application/octet-stream"
162 : "text/plain; charset=utf-8";
163}
164
165// A repo file opened directly via /raw is served from the forge's own origin.
166// HTML and SVG would render as active documents there and, despite our CSP,
167// could load a same-origin `<script src>` pointing at another raw file — a
168// stored-XSS path through the normal patch-merge flow. Serve those as plain
169// text so they can't execute; every other type keeps its real MIME so media
170// previews and downloads still work. (SVG is never shown via <img> in the
171// blob view — it renders as highlighted source — so this costs no preview.)
172// Paired with `X-Content-Type-Options: nosniff` (set globally) so a
173// text/plain body can't be sniffed back into HTML.
174const RAW_INERT_TYPES = new Set([
175 "text/html",
176 "application/xhtml+xml",
177 "image/svg+xml",
178]);
179function rawServeContentType(contentType: string): string {
180 const base = contentType.split(";")[0]!.trim().toLowerCase();
181 return RAW_INERT_TYPES.has(base)
182 ? "text/plain; charset=utf-8"
183 : contentType;
184}
185
186const README_NAMES = ["README.md", "readme.md", "README", "readme"];
187
188async function readReadme(
189 repo: string,
190 ref: string,
191 dir = "",
192 knownEntries: TreeEntry[],
193): Promise<{ content: Buffer; filename: string } | null> {
194 const prefix = dir ? `${dir}/` : "";
195 // Fast path: we already have the tree listing — find the README name and
196 // fetch only that one file, avoiding up to 3 wasted git-show calls.
197 const entryNames = new Set(knownEntries.map((e) => e.name));
198 const name = README_NAMES.find((n) => entryNames.has(n));
199 if (!name) return null;
200 const content = await git.show(repo, ref, `${prefix}${name}`);
201 return content ? { content, filename: `${prefix}${name}` } : null;
202}
203
204export const repoRoutes = new Elysia()
205 .get("/allowed_signers", () => {
206 return new Response(readFileSync(paths.ALLOWED_SIGNERS_PATH), {
207 headers: { "Content-Type": "text/plain; charset=utf-8" },
208 });
209 })
210 .guard({
211 cookie: t.Cookie({
212 session: t.Optional(t.String()),
213 repo_sort: t.Optional(t.String()),
214 }),
215 })
216 .post(
217 "/sort",
218 ({ body }) => {
219 const sort = body.sort === "name" ? "name" : "created";
220 const secure = config.PUBLIC_HTTPS ? "; Secure" : "";
221 return redirect(
222 "/",
223 `repo_sort=${sort}; Path=/; SameSite=Lax${secure}; Max-Age=${YEAR_SECONDS}`,
224 );
225 },
226 { body: t.Object({ sort: t.String() }) },
227 )
228 .get(
229 "/",
230 async ({ cookie, query }) => {
231 const user = await resolveSession(cookie.session.value);
232 const search = query.q?.trim() || undefined;
233 const page = Math.max(1, query.page ?? 1);
234 const sort = cookie.repo_sort.value === "name" ? "name" : "created";
235
236 const isAdmin = user?.isAdmin ?? false;
237
238 const searchPattern = search
239 ? `%${search.replace(/[\\%_]/g, "\\$&")}%`
240 : undefined;
241
242 const countResult = await db
243 .selectFrom("repositories")
244 .select(db.fn.countAll<number>().as("count"))
245 .where((eb) =>
246 isAdmin
247 ? eb.or([
248 eb("is_private", "=", 0),
249 eb("is_private", "=", 1),
250 ])
251 : eb("is_private", "=", 0),
252 )
253 .$if(!!searchPattern, (qb) =>
254 qb.where(
255 sql<boolean>`("name" LIKE ${searchPattern} ESCAPE '\\' OR "description" LIKE ${searchPattern} ESCAPE '\\')`,
256 ),
257 )
258 .executeTakeFirst();
259
260 const totalCount = Number(countResult?.count ?? 0);
261 const totalPages = Math.max(
262 1,
263 Math.ceil(totalCount / REPOS_PER_PAGE),
264 );
265 const safePage = Math.min(page, totalPages);
266
267 const repos = await db
268 .selectFrom("repositories")
269 .selectAll()
270 .where((eb) =>
271 isAdmin
272 ? eb.or([
273 eb("is_private", "=", 0),
274 eb("is_private", "=", 1),
275 ])
276 : eb("is_private", "=", 0),
277 )
278 .$if(!!searchPattern, (qb) =>
279 qb.where(
280 sql<boolean>`("name" LIKE ${searchPattern} ESCAPE '\\' OR "description" LIKE ${searchPattern} ESCAPE '\\')`,
281 ),
282 )
283 .orderBy("is_pinned", "desc")
284 .$if(sort === "name", (qb) => qb.orderBy("name", "asc"))
285 .$if(sort === "created", (qb) =>
286 qb.orderBy("created_at", "desc"),
287 )
288 .limit(REPOS_PER_PAGE)
289 .offset((safePage - 1) * REPOS_PER_PAGE)
290 .execute();
291
292 const searchParam = search
293 ? `&q=${encodeURIComponent(search)}`
294 : "";
295 const pagination = {
296 page: safePage,
297 totalPages,
298 pageUrlTemplate: `/?page={page}${searchParam}`,
299 };
300
301 return html(
302 <RepoList
303 user={user}
304 repos={repos}
305 search={search}
306 sort={sort}
307 pagination={pagination}
308 />,
309 );
310 },
311 {
312 query: t.Object({
313 q: t.Optional(t.String()),
314 page: t.Optional(t.Numeric()),
315 }),
316 },
317 )
318
319 .get("/new", async ({ cookie }) => {
320 const user = await resolveSession(cookie.session.value);
321 const deny = requireAdmin(user);
322 if (deny) return deny;
323 return html(<NewRepo user={user!} />);
324 })
325
326 .post(
327 "/new",
328 async ({ body, cookie }) => {
329 const user = await resolveSession(cookie.session.value);
330 const deny = requireAdmin(user);
331 if (deny) return deny;
332
333 const { name, description, is_private, default_branch } = body;
334
335 if (!VALID_REPO_NAME_RE.test(name)) {
336 return html(
337 <NewRepo user={user!} error="Invalid repository name" />,
338 );
339 }
340
341 const branch = (default_branch?.trim() || "main").replace(
342 /[^a-zA-Z0-9._/-]/g,
343 "",
344 );
345
346 const existing = await db
347 .selectFrom("repositories")
348 .select("id")
349 .where("name", "=", name)
350 .executeTakeFirst();
351 if (existing) {
352 return html(
353 <NewRepo
354 user={user!}
355 error="Repository name already taken"
356 />,
357 );
358 }
359
360 const now = new Date().toISOString();
361 await db
362 .insertInto("repositories")
363 .values({
364 name,
365 description: description || null,
366 is_private: is_private === "1" ? 1 : 0,
367 default_branch: branch,
368 created_at: now,
369 })
370 .execute();
371
372 // Initialise the git repo after the DB record is committed. If
373 // git.init fails we roll back the DB record so the two stay in sync.
374 try {
375 await git.init(name, branch);
376 } catch (err) {
377 await db
378 .deleteFrom("repositories")
379 .where("name", "=", name)
380 .execute();
381 throw err;
382 }
383 return new Response(null, {
384 status: 302,
385 headers: { Location: `/${name}` },
386 });
387 },
388 {
389 body: t.Object({
390 name: t.String(),
391 description: t.Optional(t.String()),
392 is_private: t.Optional(t.String()),
393 default_branch: t.Optional(t.String()),
394 }),
395 },
396 )
397
398 .get("/:repo", async ({ params, cookie }) => {
399 const user = await resolveSession(cookie.session.value);
400 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
401 if (!repo) return new Response("Not found", { status: 404 });
402
403 const hasContent = await git.hasCommits(repo.name);
404 let readmeHtml: string | null = null;
405 let readmePath: string | undefined;
406 let entries: Awaited<ReturnType<typeof git.lsTree>> = [];
407 let branches: string[] = [];
408 let tags: string[] = [];
409
410 if (hasContent) {
411 const [lsResult, branchResult, tagResult, resolved] =
412 await Promise.all([
413 git.lsTree(repo.name, repo.default_branch),
414 git.branches(repo.name),
415 git.tags(repo.name),
416 git.resolveRef(repo.name, repo.default_branch),
417 ]);
418 entries = lsResult;
419 branches = branchResult;
420 tags = tagResult;
421 const readme = await readReadme(
422 repo.name,
423 repo.default_branch,
424 "",
425 lsResult,
426 );
427 if (readme) {
428 const key = resolved
429 ? `readme:${repo.name}:${resolved}:`
430 : undefined;
431 readmeHtml = renderMarkdown(
432 readme.content.toString("utf-8"),
433 key,
434 {
435 repo: repo.name,
436 ref: repo.default_branch,
437 dir: "",
438 },
439 );
440 readmePath = readme.filename;
441 }
442 }
443
444 return html(
445 <RepoHome
446 user={user}
447 repo={repo}
448 entries={entries}
449 readmeHtml={readmeHtml}
450 readmePath={readmePath}
451 hasContent={hasContent}
452 branches={branches}
453 tags={tags}
454 />,
455 );
456 })
457
458 .get(
459 "/:repo/branch-switch",
460 async ({ params, query, cookie }) => {
461 const user = await resolveSession(cookie.session.value);
462 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
463 if (!repo) return new Response("Not found", { status: 404 });
464
465 const ref = query.rev?.trim();
466 if (!ref)
467 return new Response(null, {
468 status: 302,
469 headers: { Location: `/${repo.name}` },
470 });
471
472 const view = query.view;
473 const subpath = query.path ?? "";
474
475 if (view === "commits") {
476 return new Response(null, {
477 status: 302,
478 headers: { Location: `/${repo.name}/commits/${ref}` },
479 });
480 }
481 if (view === "blob" && subpath) {
482 return new Response(null, {
483 status: 302,
484 headers: {
485 Location: `/${repo.name}/blob/${ref}/${subpath}`,
486 },
487 });
488 }
489 const location = subpath
490 ? `/${repo.name}/tree/${ref}/${subpath}`
491 : `/${repo.name}/tree/${ref}`;
492 return new Response(null, {
493 status: 302,
494 headers: { Location: location },
495 });
496 },
497 {
498 query: t.Object({
499 rev: t.Optional(t.String()),
500 view: t.Optional(t.String()),
501 path: t.Optional(t.String()),
502 }),
503 },
504 )
505
506 .get("/:repo/tree/:ref", async ({ params, cookie }) => {
507 const user = await resolveSession(cookie.session.value);
508 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
509 if (!repo) return new Response("Not found", { status: 404 });
510
511 const resolved = await git.resolveRef(repo.name, params.ref);
512 if (!resolved) return new Response("Not found", { status: 404 });
513
514 const [entries, branches, tags] = await Promise.all([
515 git.lsTree(repo.name, params.ref),
516 git.branches(repo.name),
517 git.tags(repo.name),
518 ]);
519 const readme = await readReadme(repo.name, params.ref, "", entries);
520 const readmeHtml = readme
521 ? renderMarkdown(
522 readme.content.toString("utf-8"),
523 `readme:${repo.name}:${resolved}:`,
524 { repo: repo.name, ref: params.ref, dir: "" },
525 )
526 : null;
527 return html(
528 <FileTree
529 user={user}
530 repo={repo}
531 ref={params.ref}
532 subpath=""
533 entries={entries}
534 branches={branches}
535 tags={tags}
536 readmeHtml={readmeHtml}
537 readmePath={readme?.filename}
538 />,
539 );
540 })
541
542 .get("/:repo/tree/:ref/*", async ({ params, cookie }) => {
543 const user = await resolveSession(cookie.session.value);
544 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
545 if (!repo) return new Response("Not found", { status: 404 });
546
547 const resolved = await git.resolveRef(repo.name, params.ref);
548 if (!resolved) return new Response("Not found", { status: 404 });
549
550 const subpath = decodeURIComponent(params["*"]);
551 const [entries, branches, tags] = await Promise.all([
552 git.lsTree(repo.name, params.ref, subpath),
553 git.branches(repo.name),
554 git.tags(repo.name),
555 ]);
556 if (entries.length === 0) {
557 // Could be a file — redirect to blob
558 return new Response(null, {
559 status: 302,
560 headers: {
561 Location: `/${repo.name}/blob/${params.ref}/${subpath}`,
562 },
563 });
564 }
565 const readme = await readReadme(
566 repo.name,
567 params.ref,
568 subpath,
569 entries,
570 );
571 const readmeHtml = readme
572 ? renderMarkdown(
573 readme.content.toString("utf-8"),
574 `readme:${repo.name}:${resolved}:${subpath}`,
575 { repo: repo.name, ref: params.ref, dir: subpath },
576 )
577 : null;
578 return html(
579 <FileTree
580 user={user}
581 repo={repo}
582 ref={params.ref}
583 subpath={subpath}
584 entries={entries}
585 branches={branches}
586 tags={tags}
587 readmeHtml={readmeHtml}
588 readmePath={readme?.filename}
589 />,
590 );
591 })
592
593 .get("/:repo/blob/:ref/*", async ({ params, cookie }) => {
594 const user = await resolveSession(cookie.session.value);
595 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
596 if (!repo) return new Response("Not found", { status: 404 });
597
598 const filePath = decodeURIComponent(params["*"]);
599 // Size-gate before reading the blob into memory: holding a
600 // huge content buffer (and then running shiki/Bun.markdown over it)
601 // is the cheapest DOS vector against unauthenticated users on
602 // a public repo.
603 const size = await git.getFileSize(repo.name, params.ref, filePath);
604 const filename = path.basename(filePath);
605 if (size !== null && size > config.MAX_RENDER_BYTES) {
606 const [branches, tags] = await Promise.all([
607 git.branches(repo.name),
608 git.tags(repo.name),
609 ]);
610 return html(
611 <FileBlob
612 user={user}
613 repo={repo}
614 ref={params.ref}
615 filePath={filePath}
616 view={{ type: "download", size }}
617 branches={branches}
618 tags={tags}
619 markdownHtml={undefined}
620 />,
621 );
622 }
623 const [content, branches, tags, commitSHA] = await Promise.all([
624 git.show(repo.name, params.ref, filePath),
625 git.branches(repo.name),
626 git.tags(repo.name),
627 git.resolveRef(repo.name, params.ref),
628 ]);
629 if (!content || !commitSHA)
630 return new Response("Not found", { status: 404 });
631
632 const [view, markdownHtml] = await Promise.all([
633 serveFile(
634 content,
635 filename,
636 `${repo.name}:${commitSHA}:${filePath}`,
637 ),
638 /\.mdx?$/i.test(filename)
639 ? Promise.resolve(
640 renderMarkdown(
641 content.toString("utf-8"),
642 `${repo.name}:${commitSHA}:${filePath}`,
643 {
644 repo: repo.name,
645 ref: params.ref,
646 dir:
647 path.dirname(filePath) === "."
648 ? ""
649 : path.dirname(filePath),
650 },
651 ),
652 )
653 : Promise.resolve(undefined),
654 ]);
655 return html(
656 <FileBlob
657 user={user}
658 repo={repo}
659 ref={params.ref}
660 filePath={filePath}
661 view={view}
662 branches={branches}
663 tags={tags}
664 markdownHtml={markdownHtml}
665 />,
666 );
667 })
668
669 .get("/:repo/raw/:ref/*", async ({ params, cookie, request }) => {
670 const user = await resolveSession(cookie.session.value);
671 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
672 if (!repo) return new Response("Not found", { status: 404 });
673
674 const filePath = decodeURIComponent(params["*"]);
675 // Resolve the file's blob hash and size up front. cat-file -s
676 // is O(1) and lets us stream the blob below without ever
677 // holding it whole in memory — old code did
678 // `await arrayBuffer()` and sliced for Range, peaking at
679 // file_size × concurrent_requests of RSS.
680 const total = await git.getFileSize(repo.name, params.ref, filePath);
681 if (total === null) return new Response("Not found", { status: 404 });
682 if (
683 config.MAX_RAW_DOWNLOAD_BYTES > 0 &&
684 total > config.MAX_RAW_DOWNLOAD_BYTES
685 ) {
686 return new Response("File exceeds raw download size limit", {
687 status: 413,
688 });
689 }
690
691 const filename = path.basename(filePath);
692 // We use `cat-file blob` rather than `git show` so the bytes
693 // streamed exactly match what `cat-file -s` reported above —
694 // `git show` can apply smudge filters / autocrlf, which would
695 // make Content-Length wrong on filtered repos.
696 const blobArgs = [
697 "git",
698 "-C",
699 repoPath(repo.name),
700 "cat-file",
701 "blob",
702 `${params.ref}:${filePath}`,
703 ];
704
705 // Sniff content-type from the first bytes only — same idea as
706 // the old mimeForContent but without buffering the full blob.
707 const sniffStream = Bun.spawn(blobArgs, {
708 stdout: "pipe",
709 stderr: "ignore",
710 });
711 const sniffReader = sniffStream.stdout.getReader();
712 const { value: firstChunk } = await sniffReader.read();
713 sniffReader.cancel().catch(() => {});
714 sniffStream.kill();
715 const head = firstChunk
716 ? Buffer.from(firstChunk.subarray(0, BINARY_DETECT_BYTES))
717 : Buffer.alloc(0);
718 const contentType = rawServeContentType(
719 await mimeForContent(filename, head),
720 );
721
722 const rangeHeader = request.headers.get("Range");
723 const fullProc = Bun.spawn(blobArgs, {
724 stdout: "pipe",
725 stderr: "ignore",
726 });
727 if (rangeHeader) {
728 const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
729 if (match) {
730 const start = match[1] ? parseInt(match[1], 10) : 0;
731 const end = match[2] ? parseInt(match[2], 10) : total - 1;
732 const clampedEnd = Math.min(end, total - 1);
733 const length = clampedEnd - start + 1;
734 // sliceStream kills fullProc once the slice is exhausted or
735 // the consumer cancels — without this, requesting a tiny
736 // range from a huge blob leaves `git cat-file` running.
737 const sliced = sliceStream(fullProc.stdout, start, length, () =>
738 fullProc.kill(),
739 );
740 return new Response(sliced, {
741 status: 206,
742 headers: {
743 "Content-Type": contentType,
744 "Content-Range": `bytes ${start}-${clampedEnd}/${total}`,
745 "Accept-Ranges": "bytes",
746 "Content-Length": String(length),
747 },
748 });
749 }
750 }
751
752 // Wrap the full stream too so a client disconnect during a large
753 // download kills the underlying git process instead of leaving it
754 // wedged on a back-pressured pipe.
755 return new Response(streamWithKill(fullProc.stdout, fullProc), {
756 headers: {
757 "Content-Type": contentType,
758 "Content-Disposition": contentDisposition("inline", filename),
759 "Content-Length": String(total),
760 "Accept-Ranges": "bytes",
761 },
762 });
763 })
764
765 .get("/:repo/edit/:ref/*", async ({ params, query, cookie }) => {
766 const user = await resolveSession(cookie.session.value);
767 const deny = requireAdmin(user);
768 if (deny) return deny;
769 const repo = await getRepo(params.repo, true);
770 if (!repo) return new Response("Not found", { status: 404 });
771
772 const filePath = decodeURIComponent(params["*"]);
773 const branches = await git.branches(repo.name);
774 if (!branches.includes(params.ref))
775 return new Response("Not found", { status: 404 });
776
777 const content = await git.show(repo.name, params.ref, filePath);
778 if (!content) return new Response("Not found", { status: 404 });
779
780 if (hasBinaryContent(content.subarray(0, 8000)))
781 return new Response("Not found", { status: 404 });
782
783 const queryError =
784 typeof query.error === "string" ? query.error : undefined;
785 return html(
786 <FileEdit
787 user={user!}
788 repo={repo}
789 ref={params.ref}
790 filePath={filePath}
791 content={content.toString("utf-8")}
792 queryError={queryError}
793 />,
794 );
795 })
796
797 .post(
798 "/:repo/edit/:ref/*",
799 async ({ params, body, cookie }) => {
800 const user = await resolveSession(cookie.session.value);
801 const deny = requireAdmin(user);
802 if (deny) return deny;
803 const repo = await getRepo(params.repo, true);
804 if (!repo) return new Response("Not found", { status: 404 });
805
806 const filePath = decodeURIComponent(params["*"]);
807 const branches = await git.branches(repo.name);
808 if (!branches.includes(params.ref))
809 return new Response("Not found", { status: 404 });
810
811 const newPath = body.new_path?.trim() || undefined;
812 const targetPath =
813 newPath && newPath !== filePath ? newPath : filePath;
814
815 if (
816 newPath &&
817 newPath !== filePath &&
818 (newPath.startsWith("/") ||
819 newPath.includes("..") ||
820 newPath.includes("\0"))
821 ) {
822 return redirect(
823 `/${repo.name}/edit/${params.ref}/${filePath}?error=${encodeURIComponent("Invalid file path.")}`,
824 );
825 }
826
827 const defaultMessage =
828 targetPath !== filePath
829 ? `Rename ${path.basename(filePath)} to ${path.basename(targetPath)}`
830 : `Edited ${path.basename(filePath)}`;
831 const message = body.message?.trim() || defaultMessage;
832 const content = (body.content ?? "").replaceAll("\r\n", "\n");
833
834 const commit = await git.editFile(
835 repo.name,
836 params.ref,
837 filePath,
838 content,
839 message,
840 config.COMMITTER_NAME,
841 config.COMMITTER_EMAIL,
842 newPath,
843 );
844
845 return new Response(null, {
846 status: 302,
847 headers: {
848 Location: `/${repo.name}/commit/${commit}`,
849 },
850 });
851 },
852 {
853 body: t.Object({
854 content: t.Optional(t.String()),
855 message: t.Optional(t.String()),
856 new_path: t.Optional(t.String()),
857 }),
858 },
859 )
860
861 .get(
862 "/:repo/commits/:ref",
863 async ({ params, cookie, query }) => {
864 const user = await resolveSession(cookie.session.value);
865 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
866 if (!repo) return new Response("Not found", { status: 404 });
867
868 // Cursor-based pagination — O(1) regardless of history depth.
869 // `after` = SHA of the last commit on the previous page (resume cursor).
870 // `prev` = the `after` value used on the page that linked here, so we can
871 // reconstruct a "← Newer" link without a full history traversal.
872 const after = query.after?.trim() || null;
873 const prev = query.prev?.trim() || null;
874
875 const [rawCommits, branches, tags] = await Promise.all([
876 // When `after` is set: start at that SHA and skip it (--skip=1 is O(1)),
877 // then fetch LIMIT+1 to detect whether another page exists.
878 after
879 ? git.log(repo.name, after, COMMITS_PER_PAGE + 1, 1)
880 : git.log(repo.name, params.ref, COMMITS_PER_PAGE + 1, 0),
881 git.branches(repo.name),
882 git.tags(repo.name),
883 ]);
884
885 const hasNext = rawCommits.length > COMMITS_PER_PAGE;
886 const commits = rawCommits.slice(0, COMMITS_PER_PAGE);
887
888 // Build cursor URLs.
889 // "Older" advances past the last commit on this page.
890 // "Newer" goes back one page using the `prev` cursor saved in the URL,
891 // or to the first page if we're on page 2.
892 const base = `/${repo.name}/commits/${params.ref}`;
893 const olderUrl = hasNext
894 ? `${base}?after=${commits[commits.length - 1]?.hash}&prev=${after ?? ""}`
895 : null;
896 const newerUrl = after
897 ? prev
898 ? `${base}?after=${prev}`
899 : base
900 : null;
901
902 return html(
903 <CommitLog
904 user={user}
905 repo={repo}
906 ref={params.ref}
907 commits={commits}
908 branches={branches}
909 tags={tags}
910 olderUrl={olderUrl}
911 newerUrl={newerUrl}
912 />,
913 );
914 },
915 {
916 query: t.Object({
917 after: t.Optional(t.String()),
918 prev: t.Optional(t.String()),
919 }),
920 },
921 )
922
923 .get("/:repo/commit/:sha", async ({ params, cookie }) => {
924 const user = await resolveSession(cookie.session.value);
925 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
926 if (!repo) return new Response("Not found", { status: 404 });
927
928 const [meta, rawDiff] = await Promise.all([
929 git.commitMeta(repo.name, params.sha),
930 git.diff(repo.name, params.sha),
931 ]);
932 if (!meta) return new Response("Commit not found", { status: 404 });
933 // Reject oversized diffs after generation but before highlighting.
934 // Avoids running Bun.markdown / shiki / DOMPurify over a multi-megabyte
935 // diff which would synchronously stall the worker pool.
936 if (rawDiff.length > config.MAX_RENDER_BYTES) {
937 return html(
938 <CommitDetail
939 user={user}
940 repo={repo}
941 sha={params.sha}
942 meta={meta}
943 files={[]}
944 tooLarge={rawDiff.length}
945 />,
946 );
947 }
948 const files = await prepareDiff(
949 rawDiff,
950 `commit:${repo.name}:${params.sha}`,
951 repo.name,
952 );
953 return html(
954 <CommitDetail
955 user={user}
956 repo={repo}
957 sha={params.sha}
958 meta={meta}
959 files={files}
960 />,
961 );
962 })
963
964 .get("/:repo/settings", async ({ params, query, cookie }) => {
965 const user = await resolveSession(cookie.session.value);
966 const deny = requireAdmin(user);
967 if (deny) return deny;
968 const repo = await getRepo(params.repo, true);
969 if (!repo) return new Response("Not found", { status: 404 });
970 const branches = await git.branches(repo.name);
971 const [labels, secrets] = await Promise.all([
972 db
973 .selectFrom("labels")
974 .selectAll()
975 .where("repo_id", "=", repo.id)
976 .orderBy("name", "asc")
977 .execute(),
978 db
979 .selectFrom("ci_secrets")
980 .select(["id", "name", "description", "created_at"])
981 .where("repo_id", "=", repo.id)
982 .orderBy("name", "asc")
983 .execute(),
984 ]);
985 const success =
986 typeof query.success === "string" ? query.success : undefined;
987 const error = typeof query.error === "string" ? query.error : undefined;
988 return html(
989 <RepoSettings
990 user={user!}
991 repo={repo}
992 branches={branches}
993 labels={labels}
994 secrets={secrets}
995 success={success}
996 error={error}
997 />,
998 );
999 })
1000
1001 .post(
1002 "/:repo/settings",
1003 async ({ params, body, cookie }) => {
1004 const user = await resolveSession(cookie.session.value);
1005 const deny = requireAdmin(user);
1006 if (deny) return deny;
1007 const repo = await getRepo(params.repo, true);
1008 if (!repo) return new Response("Not found", { status: 404 });
1009
1010 const {
1011 description,
1012 is_private,
1013 is_pinned,
1014 allow_user_labels,
1015 default_branch,
1016 issue_template,
1017 patch_template,
1018 } = body;
1019
1020 const branches = await git.branches(repo.name);
1021 const newBranch = default_branch?.trim() || repo.default_branch;
1022
1023 // Validate the selected branch exists (only if repo has commits)
1024 if (branches.length > 0 && !branches.includes(newBranch)) {
1025 return redirect(
1026 `/${repo.name}/settings?error=${encodeURIComponent(`Branch "${newBranch}" does not exist.`)}`,
1027 );
1028 }
1029
1030 await db
1031 .updateTable("repositories")
1032 .set({
1033 description: description?.trim() || null,
1034 is_private: is_private === "1" ? 1 : 0,
1035 is_pinned: is_pinned === "1" ? 1 : 0,
1036 allow_user_labels: allow_user_labels === "1" ? 1 : 0,
1037 default_branch: newBranch,
1038 issue_template: issue_template?.trim() || null,
1039 patch_template: patch_template?.trim() || null,
1040 })
1041 .where("id", "=", repo.id)
1042 .execute();
1043
1044 // Keep git HEAD in sync if the branch actually exists
1045 if (branches.includes(newBranch)) {
1046 await git
1047 .setHead(repo.name, newBranch)
1048 .catch((e) =>
1049 console.error(
1050 `setHead failed for ${repo.name}/${newBranch}:`,
1051 e,
1052 ),
1053 );
1054 }
1055
1056 return redirect(`/${repo.name}/settings?success=Settings+saved.`);
1057 },
1058 {
1059 body: t.Object({
1060 description: t.Optional(t.String()),
1061 is_private: t.Optional(t.String()),
1062 is_pinned: t.Optional(t.String()),
1063 allow_user_labels: t.Optional(t.String()),
1064 default_branch: t.Optional(t.String()),
1065 issue_template: t.Optional(t.String()),
1066 patch_template: t.Optional(t.String()),
1067 }),
1068 },
1069 )
1070
1071 .post("/:repo/settings/delete", async ({ params, cookie }) => {
1072 const user = await resolveSession(cookie.session.value);
1073 const deny = requireAdmin(user);
1074 if (deny) return deny;
1075 const repo = await getRepo(params.repo, true);
1076 if (!repo) return new Response("Not found", { status: 404 });
1077
1078 // Remove the on-disk repo first. If this fails (e.g. permission error),
1079 // we abort before touching the DB so the repo remains accessible.
1080 rmSync(repoPath(repo.name), { recursive: true, force: true });
1081 await db.deleteFrom("repositories").where("id", "=", repo.id).execute();
1082 // Reclaim the repo's CI cache volumes (labeled by repo name), which the
1083 // DB cascade doesn't touch. Best-effort — don't block the redirect.
1084 purgeRepoCaches(repo.name).catch(() => {});
1085
1086 return new Response(null, { status: 302, headers: { Location: "/" } });
1087 })
1088
1089 .post(
1090 "/:repo/settings/rename",
1091 async ({ params, body, cookie }) => {
1092 const user = await resolveSession(cookie.session.value);
1093 const deny = requireAdmin(user);
1094 if (deny) return deny;
1095 const repo = await getRepo(params.repo, true);
1096 if (!repo) return new Response("Not found", { status: 404 });
1097
1098 const oldName = repo.name;
1099 const newName = body.new_name?.trim() ?? "";
1100 const back = (msg: string) =>
1101 redirect(
1102 `/${oldName}/settings?error=${encodeURIComponent(msg)}`,
1103 );
1104
1105 if (newName === oldName) {
1106 return back("New name is the same as the current name.");
1107 }
1108 if (newName.toLowerCase() === oldName.toLowerCase()) {
1109 return back("Case-only renames are not supported.");
1110 }
1111 if (!VALID_REPO_NAME_RE.test(newName)) {
1112 return back("Invalid repository name.");
1113 }
1114
1115 const clash = await db
1116 .selectFrom("repositories")
1117 .select("id")
1118 .where("name", "=", newName)
1119 .executeTakeFirst();
1120 if (clash) return back("Repository name already taken.");
1121
1122 const fromPath = repoPath(oldName);
1123 const toPath = repoPath(newName);
1124 if (existsSync(toPath)) {
1125 return back(
1126 "A directory for that name already exists on disk.",
1127 );
1128 }
1129
1130 try {
1131 renameSync(fromPath, toPath);
1132 } catch (err) {
1133 console.error(`rename ${fromPath} -> ${toPath} failed`, err);
1134 return back("Failed to rename repository on disk.");
1135 }
1136
1137 try {
1138 await db
1139 .updateTable("repositories")
1140 .set({ name: newName })
1141 .where("id", "=", repo.id)
1142 .execute();
1143 } catch (err) {
1144 console.error("db rename failed; rolling back disk", err);
1145 try {
1146 renameSync(toPath, fromPath);
1147 } catch (rb) {
1148 console.error("rollback rename failed", rb);
1149 }
1150 return back("Failed to update repository record.");
1151 }
1152
1153 invalidateRefCache(oldName);
1154 // CI cache volumes are labeled with the old repo name and would
1155 // otherwise detach (a run under the new name can't find them).
1156 // Purge them so caches rebuild cleanly under the new name.
1157 purgeRepoCaches(oldName).catch(() => {});
1158 return redirect(
1159 `/${newName}/settings?success=${encodeURIComponent("Repository renamed.")}`,
1160 );
1161 },
1162 {
1163 body: t.Object({
1164 new_name: t.String(),
1165 }),
1166 },
1167 )
1168
1169 .post(
1170 "/:repo/settings/labels",
1171 async ({ params, body, cookie }) => {
1172 const user = await resolveSession(cookie.session.value);
1173 const deny = requireAdmin(user);
1174 if (deny) return deny;
1175 const repo = await getRepo(params.repo, true);
1176 if (!repo) return new Response("Not found", { status: 404 });
1177
1178 const name = body.name?.trim();
1179 const color = body.color?.trim();
1180
1181 if (!name || name.length > MAX_LABEL_NAME_LENGTH) {
1182 return redirect(
1183 `/${repo.name}/settings?error=${encodeURIComponent("Label name must be 1–50 characters.")}`,
1184 );
1185 }
1186 if (!color || !/^#[0-9a-fA-F]{6}$/.test(color)) {
1187 return redirect(
1188 `/${repo.name}/settings?error=${encodeURIComponent("Invalid color.")}`,
1189 );
1190 }
1191
1192 try {
1193 await db
1194 .insertInto("labels")
1195 .values({
1196 repo_id: repo.id,
1197 name,
1198 color,
1199 created_at: new Date().toISOString(),
1200 })
1201 .execute();
1202 } catch {
1203 return redirect(
1204 `/${repo.name}/settings?error=${encodeURIComponent("A label with that name already exists.")}`,
1205 );
1206 }
1207
1208 return redirect(`/${repo.name}/settings?success=Label+created.`);
1209 },
1210 {
1211 body: t.Object({
1212 name: t.String(),
1213 color: t.String(),
1214 }),
1215 },
1216 )
1217
1218 .post(
1219 "/:repo/settings/labels/delete",
1220 async ({ params, body, cookie }) => {
1221 const user = await resolveSession(cookie.session.value);
1222 const deny = requireAdmin(user);
1223 if (deny) return deny;
1224 const repo = await getRepo(params.repo, true);
1225 if (!repo) return new Response("Not found", { status: 404 });
1226
1227 const label = await db
1228 .selectFrom("labels")
1229 .select(["id", "repo_id"])
1230 .where("id", "=", body.id)
1231 .executeTakeFirst();
1232
1233 if (!label || label.repo_id !== repo.id) {
1234 return redirect(
1235 `/${repo.name}/settings?error=${encodeURIComponent("Label not found.")}`,
1236 );
1237 }
1238
1239 await db.deleteFrom("labels").where("id", "=", body.id).execute();
1240
1241 return redirect(`/${repo.name}/settings?success=Label+deleted.`);
1242 },
1243 {
1244 body: t.Object({ id: t.Numeric() }),
1245 },
1246 )
1247
1248 // ── Branches ──────────────────────────────────────────────────────────────
1249
1250 .get("/:repo/branches", async ({ params, query, cookie }) => {
1251 const user = await resolveSession(cookie.session.value);
1252 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
1253 if (!repo) return new Response("Not found", { status: 404 });
1254 const allBranches = await git.branchesWithInfo(repo.name);
1255 const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
1256 const totalPages = Math.max(
1257 1,
1258 Math.ceil(allBranches.length / BRANCHES_PER_PAGE),
1259 );
1260 const safePage = Math.min(page, totalPages);
1261 const branches = allBranches.slice(
1262 (safePage - 1) * BRANCHES_PER_PAGE,
1263 safePage * BRANCHES_PER_PAGE,
1264 );
1265 const success =
1266 typeof query.success === "string" ? query.success : undefined;
1267 const error = typeof query.error === "string" ? query.error : undefined;
1268 return html(
1269 <BranchList
1270 user={user}
1271 repo={repo}
1272 branches={branches}
1273 page={safePage}
1274 totalPages={totalPages}
1275 success={success}
1276 error={error}
1277 />,
1278 );
1279 })
1280
1281 .post(
1282 "/:repo/branches/create",
1283 async ({ params, body, cookie }) => {
1284 const user = await resolveSession(cookie.session.value);
1285 const deny = requireAdmin(user);
1286 if (deny) return deny;
1287 const repo = await getRepo(params.repo, true);
1288 if (!repo) return new Response("Not found", { status: 404 });
1289
1290 const name = body.name?.trim() ?? "";
1291 const sourceRef = body.source_ref?.trim() ?? "";
1292
1293 if (
1294 !name ||
1295 !/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(name) ||
1296 name.includes("..") ||
1297 name.length > MAX_BRANCH_NAME_LENGTH
1298 ) {
1299 return redirect(
1300 `/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
1301 );
1302 }
1303 if (!sourceRef) {
1304 return redirect(
1305 `/${repo.name}/branches?error=${encodeURIComponent("Source ref is required.")}`,
1306 );
1307 }
1308
1309 const result = await git.createBranch(repo.name, name, sourceRef);
1310 if (result === "ok") {
1311 return redirect(
1312 `/${repo.name}/branches?success=${encodeURIComponent(`Branch "${name}" created.`)}`,
1313 );
1314 }
1315 if (result === "already_exists") {
1316 return redirect(
1317 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${name}" already exists.`)}`,
1318 );
1319 }
1320 if (result === "bad_ref") {
1321 return redirect(
1322 `/${repo.name}/branches?error=${encodeURIComponent(`"${sourceRef}" is not a valid ref.`)}`,
1323 );
1324 }
1325 return redirect(
1326 `/${repo.name}/branches?error=${encodeURIComponent("Failed to create branch.")}`,
1327 );
1328 },
1329 {
1330 body: t.Object({
1331 name: t.String(),
1332 source_ref: t.String(),
1333 }),
1334 },
1335 )
1336
1337 .post(
1338 "/:repo/branches/delete",
1339 async ({ params, body, cookie }) => {
1340 const user = await resolveSession(cookie.session.value);
1341 const deny = requireAdmin(user);
1342 if (deny) return deny;
1343 const repo = await getRepo(params.repo, true);
1344 if (!repo) return new Response("Not found", { status: 404 });
1345
1346 const name = body.name?.trim() ?? "";
1347 if (!name) {
1348 return redirect(
1349 `/${repo.name}/branches?error=${encodeURIComponent("Branch name is required.")}`,
1350 );
1351 }
1352 if (name === repo.default_branch) {
1353 return redirect(
1354 `/${repo.name}/branches?error=${encodeURIComponent("Cannot delete the default branch.")}`,
1355 );
1356 }
1357
1358 const result = await git.deleteBranch(repo.name, name);
1359 if (result === "ok") {
1360 return redirect(
1361 `/${repo.name}/branches?success=${encodeURIComponent(`Branch "${name}" deleted.`)}`,
1362 );
1363 }
1364 if (result === "not_found") {
1365 return redirect(
1366 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${name}" not found.`)}`,
1367 );
1368 }
1369 return redirect(
1370 `/${repo.name}/branches?error=${encodeURIComponent("Failed to delete branch.")}`,
1371 );
1372 },
1373 {
1374 body: t.Object({ name: t.String() }),
1375 },
1376 )
1377
1378 .post(
1379 "/:repo/branches/rename",
1380 async ({ params, body, cookie }) => {
1381 const user = await resolveSession(cookie.session.value);
1382 const deny = requireAdmin(user);
1383 if (deny) return deny;
1384 const repo = await getRepo(params.repo, true);
1385 if (!repo) return new Response("Not found", { status: 404 });
1386
1387 const oldName = body.old_name?.trim() ?? "";
1388 const newName = body.new_name?.trim() ?? "";
1389
1390 if (
1391 !newName ||
1392 !/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(newName) ||
1393 newName.includes("..") ||
1394 newName.length > MAX_BRANCH_NAME_LENGTH
1395 ) {
1396 return redirect(
1397 `/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
1398 );
1399 }
1400
1401 const result = await git.renameBranch(repo.name, oldName, newName);
1402 if (result === "ok") {
1403 // Keep default_branch in DB in sync if we renamed it
1404 if (oldName === repo.default_branch) {
1405 await db
1406 .updateTable("repositories")
1407 .set({ default_branch: newName })
1408 .where("id", "=", repo.id)
1409 .execute();
1410 await git
1411 .setHead(repo.name, newName)
1412 .catch((e) =>
1413 console.error(
1414 `setHead failed for ${repo.name}/${newName}:`,
1415 e,
1416 ),
1417 );
1418 }
1419 return redirect(
1420 `/${repo.name}/branches?success=${encodeURIComponent(`Branch renamed to "${newName}".`)}`,
1421 );
1422 }
1423 if (result === "not_found") {
1424 return redirect(
1425 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${oldName}" not found.`)}`,
1426 );
1427 }
1428 if (result === "already_exists") {
1429 return redirect(
1430 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${newName}" already exists.`)}`,
1431 );
1432 }
1433 return redirect(
1434 `/${repo.name}/branches?error=${encodeURIComponent("Failed to rename branch.")}`,
1435 );
1436 },
1437 {
1438 body: t.Object({
1439 old_name: t.String(),
1440 new_name: t.String(),
1441 }),
1442 },
1443 )
1444
1445 // ── Tags ──────────────────────────────────────────────────────────────────
1446
1447 .get("/:repo/tags", async ({ params, query, cookie }) => {
1448 const user = await resolveSession(cookie.session.value);
1449 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
1450 if (!repo) return new Response("Not found", { status: 404 });
1451 const [allTags, releases] = await Promise.all([
1452 git.tagsWithInfo(repo.name),
1453 db
1454 .selectFrom("releases")
1455 .select(["id", "tag_name"])
1456 .where("repo_id", "=", repo.id)
1457 .where("tag_name", "is not", null)
1458 .execute(),
1459 ]);
1460 const tagReleaseMap = new Map<string, number>();
1461 for (const r of releases) {
1462 if (r.tag_name) tagReleaseMap.set(r.tag_name, r.id);
1463 }
1464 const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
1465 const totalPages = Math.max(
1466 1,
1467 Math.ceil(allTags.length / TAGS_PER_PAGE),
1468 );
1469 const safePage = Math.min(page, totalPages);
1470 const tags = allTags.slice(
1471 (safePage - 1) * TAGS_PER_PAGE,
1472 safePage * TAGS_PER_PAGE,
1473 );
1474 const success =
1475 typeof query.success === "string" ? query.success : undefined;
1476 const error = typeof query.error === "string" ? query.error : undefined;
1477 return html(
1478 <TagList
1479 user={user}
1480 repo={repo}
1481 tags={tags}
1482 tagReleaseMap={tagReleaseMap}
1483 page={safePage}
1484 totalPages={totalPages}
1485 success={success}
1486 error={error}
1487 />,
1488 );
1489 })
1490
1491 .post(
1492 "/:repo/tags/create",
1493 async ({ params, body, cookie }) => {
1494 const user = await resolveSession(cookie.session.value);
1495 const deny = requireAdmin(user);
1496 if (deny) return deny;
1497 const repo = await getRepo(params.repo, true);
1498 if (!repo) return new Response("Not found", { status: 404 });
1499
1500 const tagName = body.name?.trim() ?? "";
1501 const ref = body.ref?.trim() ?? "";
1502 const message = body.message?.trim() || undefined;
1503
1504 if (!tagName || !/^[a-zA-Z0-9._\-+]+$/.test(tagName)) {
1505 return redirect(
1506 `/${repo.name}/tags?error=${encodeURIComponent("Invalid tag name.")}`,
1507 );
1508 }
1509 if (!ref) {
1510 return redirect(
1511 `/${repo.name}/tags?error=${encodeURIComponent("Target ref is required.")}`,
1512 );
1513 }
1514
1515 const result = await git.createTag(
1516 repo.name,
1517 tagName,
1518 ref,
1519 message,
1520 message ? config.COMMITTER_NAME : undefined,
1521 message ? config.COMMITTER_EMAIL : undefined,
1522 );
1523 if (result === "ok") {
1524 return redirect(
1525 `/${repo.name}/tags?success=${encodeURIComponent(`Tag "${tagName}" created.`)}`,
1526 );
1527 }
1528 if (result === "already_exists") {
1529 return redirect(
1530 `/${repo.name}/tags?error=${encodeURIComponent(`Tag "${tagName}" already exists.`)}`,
1531 );
1532 }
1533 if (result === "bad_ref") {
1534 return redirect(
1535 `/${repo.name}/tags?error=${encodeURIComponent(`"${ref}" is not a valid ref.`)}`,
1536 );
1537 }
1538 return redirect(
1539 `/${repo.name}/tags?error=${encodeURIComponent("Failed to create tag.")}`,
1540 );
1541 },
1542 {
1543 body: t.Object({
1544 name: t.String(),
1545 ref: t.String(),
1546 message: t.Optional(t.String()),
1547 }),
1548 },
1549 )
1550
1551 .post(
1552 "/:repo/tags/delete",
1553 async ({ params, body, cookie }) => {
1554 const user = await resolveSession(cookie.session.value);
1555 const deny = requireAdmin(user);
1556 if (deny) return deny;
1557 const repo = await getRepo(params.repo, true);
1558 if (!repo) return new Response("Not found", { status: 404 });
1559
1560 const tagName = body.name?.trim() ?? "";
1561 if (!tagName) {
1562 return redirect(
1563 `/${repo.name}/tags?error=${encodeURIComponent("Tag name is required.")}`,
1564 );
1565 }
1566
1567 const result = await git.deleteTag(repo.name, tagName);
1568 if (result === "ok") {
1569 return redirect(
1570 `/${repo.name}/tags?success=${encodeURIComponent(`Tag "${tagName}" deleted.`)}`,
1571 );
1572 }
1573 if (result === "not_found") {
1574 return redirect(
1575 `/${repo.name}/tags?error=${encodeURIComponent(`Tag "${tagName}" not found.`)}`,
1576 );
1577 }
1578 return redirect(
1579 `/${repo.name}/tags?error=${encodeURIComponent("Failed to delete tag.")}`,
1580 );
1581 },
1582 {
1583 body: t.Object({ name: t.String() }),
1584 },
1585 )
1586
1587 // ── File creation ─────────────────────────────────────────────────────────
1588
1589 .get("/:repo/new-file/:ref", async ({ params, query, cookie }) => {
1590 const user = await resolveSession(cookie.session.value);
1591 const deny = requireAdmin(user);
1592 if (deny) return deny;
1593 const repo = await getRepo(params.repo, true);
1594 if (!repo) return new Response("Not found", { status: 404 });
1595 const dir = typeof query.dir === "string" ? query.dir : "";
1596 const error = typeof query.error === "string" ? query.error : undefined;
1597 return html(
1598 <NewFileForm
1599 user={user!}
1600 repo={repo}
1601 ref={params.ref}
1602 dir={dir}
1603 error={error}
1604 />,
1605 );
1606 })
1607
1608 .post(
1609 "/:repo/new-file/:ref",
1610 async ({ params, body, cookie }) => {
1611 const user = await resolveSession(cookie.session.value);
1612 const deny = requireAdmin(user);
1613 if (deny) return deny;
1614 const repo = await getRepo(params.repo, true);
1615 if (!repo) return new Response("Not found", { status: 404 });
1616
1617 const filePath = body.path?.trim() ?? "";
1618 const content = body.content ?? "";
1619 const message = body.message?.trim() || `Add ${filePath}`;
1620
1621 if (
1622 !filePath ||
1623 filePath.startsWith("/") ||
1624 filePath.includes("..") ||
1625 filePath.includes("\0")
1626 ) {
1627 return redirect(
1628 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Invalid file path.")}`,
1629 );
1630 }
1631
1632 // Ensure we're on a branch
1633 const branches = await git.branches(repo.name);
1634 if (branches.length > 0 && !branches.includes(params.ref)) {
1635 return redirect(
1636 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Can only create files on a branch.")}`,
1637 );
1638 }
1639
1640 try {
1641 const commit = await git.createFile(
1642 repo.name,
1643 params.ref,
1644 filePath,
1645 content,
1646 message,
1647 config.COMMITTER_NAME,
1648 config.COMMITTER_EMAIL,
1649 );
1650 return redirect(`/${repo.name}/commit/${commit}`);
1651 } catch {
1652 return redirect(
1653 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Failed to create file.")}`,
1654 );
1655 }
1656 },
1657 {
1658 body: t.Object({
1659 path: t.String(),
1660 content: t.Optional(t.String()),
1661 message: t.Optional(t.String()),
1662 }),
1663 },
1664 )
1665
1666 // ── File deletion ─────────────────────────────────────────────────────────
1667
1668 .post(
1669 "/:repo/delete-file/:ref/*",
1670 async ({ params, body, cookie }) => {
1671 const user = await resolveSession(cookie.session.value);
1672 const deny = requireAdmin(user);
1673 if (deny) return deny;
1674 const repo = await getRepo(params.repo, true);
1675 if (!repo) return new Response("Not found", { status: 404 });
1676
1677 const filePath = decodeURIComponent(params["*"]);
1678 const message = body.message?.trim() || `Delete ${filePath}`;
1679
1680 try {
1681 const commit = await git.deleteFile(
1682 repo.name,
1683 params.ref,
1684 filePath,
1685 message,
1686 config.COMMITTER_NAME,
1687 config.COMMITTER_EMAIL,
1688 );
1689 return redirect(`/${repo.name}/commit/${commit}`);
1690 } catch {
1691 return redirect(
1692 `/${repo.name}/blob/${params.ref}/${filePath}?error=${encodeURIComponent("Failed to delete file.")}`,
1693 );
1694 }
1695 },
1696 {
1697 body: t.Object({
1698 message: t.Optional(t.String()),
1699 }),
1700 },
1701 );
1702