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, query, 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 const blobError =
606 typeof query.error === "string" ? query.error : undefined;
607 if (size !== null && size > config.MAX_RENDER_BYTES) {
608 const [branches, tags] = await Promise.all([
609 git.branches(repo.name),
610 git.tags(repo.name),
611 ]);
612 return html(
613 <FileBlob
614 user={user}
615 repo={repo}
616 ref={params.ref}
617 filePath={filePath}
618 view={{ type: "download", size }}
619 branches={branches}
620 tags={tags}
621 markdownHtml={undefined}
622 error={blobError}
623 />,
624 );
625 }
626 const [content, branches, tags, commitSHA] = await Promise.all([
627 git.show(repo.name, params.ref, filePath),
628 git.branches(repo.name),
629 git.tags(repo.name),
630 git.resolveRef(repo.name, params.ref),
631 ]);
632 if (!content || !commitSHA)
633 return new Response("Not found", { status: 404 });
634
635 const [view, markdownHtml] = await Promise.all([
636 serveFile(
637 content,
638 filename,
639 `${repo.name}:${commitSHA}:${filePath}`,
640 ),
641 /\.mdx?$/i.test(filename)
642 ? Promise.resolve(
643 renderMarkdown(
644 content.toString("utf-8"),
645 `${repo.name}:${commitSHA}:${filePath}`,
646 {
647 repo: repo.name,
648 ref: params.ref,
649 dir:
650 path.dirname(filePath) === "."
651 ? ""
652 : path.dirname(filePath),
653 },
654 ),
655 )
656 : Promise.resolve(undefined),
657 ]);
658 return html(
659 <FileBlob
660 user={user}
661 repo={repo}
662 ref={params.ref}
663 filePath={filePath}
664 view={view}
665 branches={branches}
666 tags={tags}
667 markdownHtml={markdownHtml}
668 error={blobError}
669 />,
670 );
671 })
672
673 .get("/:repo/raw/:ref/*", async ({ params, cookie, request }) => {
674 const user = await resolveSession(cookie.session.value);
675 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
676 if (!repo) return new Response("Not found", { status: 404 });
677
678 const filePath = decodeURIComponent(params["*"]);
679 // Resolve the file's blob hash and size up front. cat-file -s
680 // is O(1) and lets us stream the blob below without ever
681 // holding it whole in memory — old code did
682 // `await arrayBuffer()` and sliced for Range, peaking at
683 // file_size × concurrent_requests of RSS.
684 const total = await git.getFileSize(repo.name, params.ref, filePath);
685 if (total === null) return new Response("Not found", { status: 404 });
686 if (
687 config.MAX_RAW_DOWNLOAD_BYTES > 0 &&
688 total > config.MAX_RAW_DOWNLOAD_BYTES
689 ) {
690 return new Response("File exceeds raw download size limit", {
691 status: 413,
692 });
693 }
694
695 const filename = path.basename(filePath);
696 // We use `cat-file blob` rather than `git show` so the bytes
697 // streamed exactly match what `cat-file -s` reported above —
698 // `git show` can apply smudge filters / autocrlf, which would
699 // make Content-Length wrong on filtered repos.
700 const blobArgs = [
701 "git",
702 "-C",
703 repoPath(repo.name),
704 "cat-file",
705 "blob",
706 `${params.ref}:${filePath}`,
707 ];
708
709 // Sniff content-type from the first bytes only — same idea as
710 // the old mimeForContent but without buffering the full blob.
711 const sniffStream = Bun.spawn(blobArgs, {
712 stdout: "pipe",
713 stderr: "ignore",
714 });
715 const sniffReader = sniffStream.stdout.getReader();
716 const { value: firstChunk } = await sniffReader.read();
717 sniffReader.cancel().catch(() => {});
718 sniffStream.kill();
719 const head = firstChunk
720 ? Buffer.from(firstChunk.subarray(0, BINARY_DETECT_BYTES))
721 : Buffer.alloc(0);
722 const contentType = rawServeContentType(
723 await mimeForContent(filename, head),
724 );
725
726 const rangeHeader = request.headers.get("Range");
727 const fullProc = Bun.spawn(blobArgs, {
728 stdout: "pipe",
729 stderr: "ignore",
730 });
731 if (rangeHeader) {
732 const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
733 if (match) {
734 const start = match[1] ? parseInt(match[1], 10) : 0;
735 const end = match[2] ? parseInt(match[2], 10) : total - 1;
736 const clampedEnd = Math.min(end, total - 1);
737 const length = clampedEnd - start + 1;
738 // sliceStream kills fullProc once the slice is exhausted or
739 // the consumer cancels — without this, requesting a tiny
740 // range from a huge blob leaves `git cat-file` running.
741 const sliced = sliceStream(fullProc.stdout, start, length, () =>
742 fullProc.kill(),
743 );
744 return new Response(sliced, {
745 status: 206,
746 headers: {
747 "Content-Type": contentType,
748 "Content-Range": `bytes ${start}-${clampedEnd}/${total}`,
749 "Accept-Ranges": "bytes",
750 "Content-Length": String(length),
751 },
752 });
753 }
754 }
755
756 // Wrap the full stream too so a client disconnect during a large
757 // download kills the underlying git process instead of leaving it
758 // wedged on a back-pressured pipe.
759 return new Response(streamWithKill(fullProc.stdout, fullProc), {
760 headers: {
761 "Content-Type": contentType,
762 "Content-Disposition": contentDisposition("inline", filename),
763 "Content-Length": String(total),
764 "Accept-Ranges": "bytes",
765 },
766 });
767 })
768
769 .get("/:repo/edit/:ref/*", async ({ params, query, cookie }) => {
770 const user = await resolveSession(cookie.session.value);
771 const deny = requireAdmin(user);
772 if (deny) return deny;
773 const repo = await getRepo(params.repo, true);
774 if (!repo) return new Response("Not found", { status: 404 });
775
776 const filePath = decodeURIComponent(params["*"]);
777 const branches = await git.branches(repo.name);
778 if (!branches.includes(params.ref))
779 return new Response("Not found", { status: 404 });
780
781 const content = await git.show(repo.name, params.ref, filePath);
782 if (!content) return new Response("Not found", { status: 404 });
783
784 if (hasBinaryContent(content.subarray(0, 8000)))
785 return new Response("Not found", { status: 404 });
786
787 const queryError =
788 typeof query.error === "string" ? query.error : undefined;
789 return html(
790 <FileEdit
791 user={user!}
792 repo={repo}
793 ref={params.ref}
794 filePath={filePath}
795 content={content.toString("utf-8")}
796 queryError={queryError}
797 />,
798 );
799 })
800
801 .post(
802 "/:repo/edit/:ref/*",
803 async ({ params, body, cookie }) => {
804 const user = await resolveSession(cookie.session.value);
805 const deny = requireAdmin(user);
806 if (deny) return deny;
807 const repo = await getRepo(params.repo, true);
808 if (!repo) return new Response("Not found", { status: 404 });
809
810 const filePath = decodeURIComponent(params["*"]);
811 const branches = await git.branches(repo.name);
812 if (!branches.includes(params.ref))
813 return new Response("Not found", { status: 404 });
814
815 const newPath = body.new_path?.trim() || undefined;
816 const targetPath =
817 newPath && newPath !== filePath ? newPath : filePath;
818
819 if (
820 newPath &&
821 newPath !== filePath &&
822 (newPath.startsWith("/") ||
823 newPath.includes("..") ||
824 newPath.includes("\0"))
825 ) {
826 return redirect(
827 `/${repo.name}/edit/${params.ref}/${filePath}?error=${encodeURIComponent("Invalid file path.")}`,
828 );
829 }
830
831 const defaultMessage =
832 targetPath !== filePath
833 ? `Rename ${path.basename(filePath)} to ${path.basename(targetPath)}`
834 : `Edited ${path.basename(filePath)}`;
835 const message = body.message?.trim() || defaultMessage;
836 const content = (body.content ?? "").replaceAll("\r\n", "\n");
837
838 const commit = await git.editFile(
839 repo.name,
840 params.ref,
841 filePath,
842 content,
843 message,
844 config.COMMITTER_NAME,
845 config.COMMITTER_EMAIL,
846 newPath,
847 );
848
849 return new Response(null, {
850 status: 302,
851 headers: {
852 Location: `/${repo.name}/commit/${commit}`,
853 },
854 });
855 },
856 {
857 body: t.Object({
858 content: t.Optional(t.String()),
859 message: t.Optional(t.String()),
860 new_path: t.Optional(t.String()),
861 }),
862 },
863 )
864
865 .get(
866 "/:repo/commits/:ref",
867 async ({ params, cookie, query }) => {
868 const user = await resolveSession(cookie.session.value);
869 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
870 if (!repo) return new Response("Not found", { status: 404 });
871
872 // Cursor-based pagination — O(1) regardless of history depth.
873 // `after` = SHA of the last commit on the previous page (resume cursor).
874 // `prev` = the `after` value used on the page that linked here, so we can
875 // reconstruct a "← Newer" link without a full history traversal.
876 const after = query.after?.trim() || null;
877 const prev = query.prev?.trim() || null;
878
879 const [rawCommits, branches, tags] = await Promise.all([
880 // When `after` is set: start at that SHA and skip it (--skip=1 is O(1)),
881 // then fetch LIMIT+1 to detect whether another page exists.
882 after
883 ? git.log(repo.name, after, COMMITS_PER_PAGE + 1, 1)
884 : git.log(repo.name, params.ref, COMMITS_PER_PAGE + 1, 0),
885 git.branches(repo.name),
886 git.tags(repo.name),
887 ]);
888
889 const hasNext = rawCommits.length > COMMITS_PER_PAGE;
890 const commits = rawCommits.slice(0, COMMITS_PER_PAGE);
891
892 // Build cursor URLs.
893 // "Older" advances past the last commit on this page.
894 // "Newer" goes back one page using the `prev` cursor saved in the URL,
895 // or to the first page if we're on page 2.
896 const base = `/${repo.name}/commits/${params.ref}`;
897 const olderUrl = hasNext
898 ? `${base}?after=${commits[commits.length - 1]?.hash}&prev=${after ?? ""}`
899 : null;
900 const newerUrl = after
901 ? prev
902 ? `${base}?after=${prev}`
903 : base
904 : null;
905
906 return html(
907 <CommitLog
908 user={user}
909 repo={repo}
910 ref={params.ref}
911 commits={commits}
912 branches={branches}
913 tags={tags}
914 olderUrl={olderUrl}
915 newerUrl={newerUrl}
916 />,
917 );
918 },
919 {
920 query: t.Object({
921 after: t.Optional(t.String()),
922 prev: t.Optional(t.String()),
923 }),
924 },
925 )
926
927 .get("/:repo/commit/:sha", async ({ params, cookie }) => {
928 const user = await resolveSession(cookie.session.value);
929 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
930 if (!repo) return new Response("Not found", { status: 404 });
931
932 const [meta, rawDiff] = await Promise.all([
933 git.commitMeta(repo.name, params.sha),
934 git.diff(repo.name, params.sha),
935 ]);
936 if (!meta) return new Response("Commit not found", { status: 404 });
937 // Reject oversized diffs after generation but before highlighting.
938 // Avoids running Bun.markdown / shiki / DOMPurify over a multi-megabyte
939 // diff which would synchronously stall the worker pool.
940 if (rawDiff.length > config.MAX_RENDER_BYTES) {
941 return html(
942 <CommitDetail
943 user={user}
944 repo={repo}
945 sha={params.sha}
946 meta={meta}
947 files={[]}
948 tooLarge={rawDiff.length}
949 />,
950 );
951 }
952 const files = await prepareDiff(
953 rawDiff,
954 `commit:${repo.name}:${params.sha}`,
955 repo.name,
956 );
957 return html(
958 <CommitDetail
959 user={user}
960 repo={repo}
961 sha={params.sha}
962 meta={meta}
963 files={files}
964 />,
965 );
966 })
967
968 .get("/:repo/settings", async ({ params, query, cookie }) => {
969 const user = await resolveSession(cookie.session.value);
970 const deny = requireAdmin(user);
971 if (deny) return deny;
972 const repo = await getRepo(params.repo, true);
973 if (!repo) return new Response("Not found", { status: 404 });
974 const branches = await git.branches(repo.name);
975 const [labels, secrets] = await Promise.all([
976 db
977 .selectFrom("labels")
978 .selectAll()
979 .where("repo_id", "=", repo.id)
980 .orderBy("name", "asc")
981 .execute(),
982 db
983 .selectFrom("ci_secrets")
984 .select(["id", "name", "description", "created_at"])
985 .where("repo_id", "=", repo.id)
986 .orderBy("name", "asc")
987 .execute(),
988 ]);
989 const success =
990 typeof query.success === "string" ? query.success : undefined;
991 const error = typeof query.error === "string" ? query.error : undefined;
992 return html(
993 <RepoSettings
994 user={user!}
995 repo={repo}
996 branches={branches}
997 labels={labels}
998 secrets={secrets}
999 success={success}
1000 error={error}
1001 />,
1002 );
1003 })
1004
1005 .post(
1006 "/:repo/settings",
1007 async ({ params, body, cookie }) => {
1008 const user = await resolveSession(cookie.session.value);
1009 const deny = requireAdmin(user);
1010 if (deny) return deny;
1011 const repo = await getRepo(params.repo, true);
1012 if (!repo) return new Response("Not found", { status: 404 });
1013
1014 const {
1015 description,
1016 is_private,
1017 is_pinned,
1018 allow_user_labels,
1019 default_branch,
1020 issue_template,
1021 patch_template,
1022 } = body;
1023
1024 const branches = await git.branches(repo.name);
1025 const newBranch = default_branch?.trim() || repo.default_branch;
1026
1027 // Validate the selected branch exists (only if repo has commits)
1028 if (branches.length > 0 && !branches.includes(newBranch)) {
1029 return redirect(
1030 `/${repo.name}/settings?error=${encodeURIComponent(`Branch "${newBranch}" does not exist.`)}`,
1031 );
1032 }
1033
1034 await db
1035 .updateTable("repositories")
1036 .set({
1037 description: description?.trim() || null,
1038 is_private: is_private === "1" ? 1 : 0,
1039 is_pinned: is_pinned === "1" ? 1 : 0,
1040 allow_user_labels: allow_user_labels === "1" ? 1 : 0,
1041 default_branch: newBranch,
1042 issue_template: issue_template?.trim() || null,
1043 patch_template: patch_template?.trim() || null,
1044 })
1045 .where("id", "=", repo.id)
1046 .execute();
1047
1048 // Keep git HEAD in sync if the branch actually exists
1049 if (branches.includes(newBranch)) {
1050 await git
1051 .setHead(repo.name, newBranch)
1052 .catch((e) =>
1053 console.error(
1054 `setHead failed for ${repo.name}/${newBranch}:`,
1055 e,
1056 ),
1057 );
1058 }
1059
1060 return redirect(`/${repo.name}/settings?success=Settings+saved.`);
1061 },
1062 {
1063 body: t.Object({
1064 description: t.Optional(t.String()),
1065 is_private: t.Optional(t.String()),
1066 is_pinned: t.Optional(t.String()),
1067 allow_user_labels: t.Optional(t.String()),
1068 default_branch: t.Optional(t.String()),
1069 issue_template: t.Optional(t.String()),
1070 patch_template: t.Optional(t.String()),
1071 }),
1072 },
1073 )
1074
1075 .post("/:repo/settings/delete", async ({ params, cookie }) => {
1076 const user = await resolveSession(cookie.session.value);
1077 const deny = requireAdmin(user);
1078 if (deny) return deny;
1079 const repo = await getRepo(params.repo, true);
1080 if (!repo) return new Response("Not found", { status: 404 });
1081
1082 // Remove the on-disk repo first. If this fails (e.g. permission error),
1083 // we abort before touching the DB so the repo remains accessible.
1084 rmSync(repoPath(repo.name), { recursive: true, force: true });
1085 await db.deleteFrom("repositories").where("id", "=", repo.id).execute();
1086 // Reclaim the repo's CI cache volumes (labeled by repo name), which the
1087 // DB cascade doesn't touch. Best-effort — don't block the redirect.
1088 purgeRepoCaches(repo.name).catch(() => {});
1089
1090 return new Response(null, { status: 302, headers: { Location: "/" } });
1091 })
1092
1093 .post(
1094 "/:repo/settings/rename",
1095 async ({ params, body, cookie }) => {
1096 const user = await resolveSession(cookie.session.value);
1097 const deny = requireAdmin(user);
1098 if (deny) return deny;
1099 const repo = await getRepo(params.repo, true);
1100 if (!repo) return new Response("Not found", { status: 404 });
1101
1102 const oldName = repo.name;
1103 const newName = body.new_name?.trim() ?? "";
1104 const back = (msg: string) =>
1105 redirect(
1106 `/${oldName}/settings?error=${encodeURIComponent(msg)}`,
1107 );
1108
1109 if (newName === oldName) {
1110 return back("New name is the same as the current name.");
1111 }
1112 if (newName.toLowerCase() === oldName.toLowerCase()) {
1113 return back("Case-only renames are not supported.");
1114 }
1115 if (!VALID_REPO_NAME_RE.test(newName)) {
1116 return back("Invalid repository name.");
1117 }
1118
1119 const clash = await db
1120 .selectFrom("repositories")
1121 .select("id")
1122 .where("name", "=", newName)
1123 .executeTakeFirst();
1124 if (clash) return back("Repository name already taken.");
1125
1126 const fromPath = repoPath(oldName);
1127 const toPath = repoPath(newName);
1128 if (existsSync(toPath)) {
1129 return back(
1130 "A directory for that name already exists on disk.",
1131 );
1132 }
1133
1134 try {
1135 renameSync(fromPath, toPath);
1136 } catch (err) {
1137 console.error(`rename ${fromPath} -> ${toPath} failed`, err);
1138 return back("Failed to rename repository on disk.");
1139 }
1140
1141 try {
1142 await db
1143 .updateTable("repositories")
1144 .set({ name: newName })
1145 .where("id", "=", repo.id)
1146 .execute();
1147 } catch (err) {
1148 console.error("db rename failed; rolling back disk", err);
1149 try {
1150 renameSync(toPath, fromPath);
1151 } catch (rb) {
1152 console.error("rollback rename failed", rb);
1153 }
1154 return back("Failed to update repository record.");
1155 }
1156
1157 invalidateRefCache(oldName);
1158 // CI cache volumes are labeled with the old repo name and would
1159 // otherwise detach (a run under the new name can't find them).
1160 // Purge them so caches rebuild cleanly under the new name.
1161 purgeRepoCaches(oldName).catch(() => {});
1162 return redirect(
1163 `/${newName}/settings?success=${encodeURIComponent("Repository renamed.")}`,
1164 );
1165 },
1166 {
1167 body: t.Object({
1168 new_name: t.String(),
1169 }),
1170 },
1171 )
1172
1173 .post(
1174 "/:repo/settings/labels",
1175 async ({ params, body, cookie }) => {
1176 const user = await resolveSession(cookie.session.value);
1177 const deny = requireAdmin(user);
1178 if (deny) return deny;
1179 const repo = await getRepo(params.repo, true);
1180 if (!repo) return new Response("Not found", { status: 404 });
1181
1182 const name = body.name?.trim();
1183 const color = body.color?.trim();
1184
1185 if (!name || name.length > MAX_LABEL_NAME_LENGTH) {
1186 return redirect(
1187 `/${repo.name}/settings?error=${encodeURIComponent("Label name must be 1–50 characters.")}`,
1188 );
1189 }
1190 if (!color || !/^#[0-9a-fA-F]{6}$/.test(color)) {
1191 return redirect(
1192 `/${repo.name}/settings?error=${encodeURIComponent("Invalid color.")}`,
1193 );
1194 }
1195
1196 try {
1197 await db
1198 .insertInto("labels")
1199 .values({
1200 repo_id: repo.id,
1201 name,
1202 color,
1203 created_at: new Date().toISOString(),
1204 })
1205 .execute();
1206 } catch {
1207 return redirect(
1208 `/${repo.name}/settings?error=${encodeURIComponent("A label with that name already exists.")}`,
1209 );
1210 }
1211
1212 return redirect(`/${repo.name}/settings?success=Label+created.`);
1213 },
1214 {
1215 body: t.Object({
1216 name: t.String(),
1217 color: t.String(),
1218 }),
1219 },
1220 )
1221
1222 .post(
1223 "/:repo/settings/labels/delete",
1224 async ({ params, body, cookie }) => {
1225 const user = await resolveSession(cookie.session.value);
1226 const deny = requireAdmin(user);
1227 if (deny) return deny;
1228 const repo = await getRepo(params.repo, true);
1229 if (!repo) return new Response("Not found", { status: 404 });
1230
1231 const label = await db
1232 .selectFrom("labels")
1233 .select(["id", "repo_id"])
1234 .where("id", "=", body.id)
1235 .executeTakeFirst();
1236
1237 if (!label || label.repo_id !== repo.id) {
1238 return redirect(
1239 `/${repo.name}/settings?error=${encodeURIComponent("Label not found.")}`,
1240 );
1241 }
1242
1243 await db.deleteFrom("labels").where("id", "=", body.id).execute();
1244
1245 return redirect(`/${repo.name}/settings?success=Label+deleted.`);
1246 },
1247 {
1248 body: t.Object({ id: t.Numeric() }),
1249 },
1250 )
1251
1252 // ── Branches ──────────────────────────────────────────────────────────────
1253
1254 .get("/:repo/branches", async ({ params, query, cookie }) => {
1255 const user = await resolveSession(cookie.session.value);
1256 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
1257 if (!repo) return new Response("Not found", { status: 404 });
1258 const allBranches = await git.branchesWithInfo(repo.name);
1259 const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
1260 const totalPages = Math.max(
1261 1,
1262 Math.ceil(allBranches.length / BRANCHES_PER_PAGE),
1263 );
1264 const safePage = Math.min(page, totalPages);
1265 const branches = allBranches.slice(
1266 (safePage - 1) * BRANCHES_PER_PAGE,
1267 safePage * BRANCHES_PER_PAGE,
1268 );
1269 const success =
1270 typeof query.success === "string" ? query.success : undefined;
1271 const error = typeof query.error === "string" ? query.error : undefined;
1272 return html(
1273 <BranchList
1274 user={user}
1275 repo={repo}
1276 branches={branches}
1277 page={safePage}
1278 totalPages={totalPages}
1279 success={success}
1280 error={error}
1281 />,
1282 );
1283 })
1284
1285 .post(
1286 "/:repo/branches/create",
1287 async ({ params, body, cookie }) => {
1288 const user = await resolveSession(cookie.session.value);
1289 const deny = requireAdmin(user);
1290 if (deny) return deny;
1291 const repo = await getRepo(params.repo, true);
1292 if (!repo) return new Response("Not found", { status: 404 });
1293
1294 const name = body.name?.trim() ?? "";
1295 const sourceRef = body.source_ref?.trim() ?? "";
1296
1297 if (
1298 !name ||
1299 !/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(name) ||
1300 name.includes("..") ||
1301 name.length > MAX_BRANCH_NAME_LENGTH
1302 ) {
1303 return redirect(
1304 `/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
1305 );
1306 }
1307 if (!sourceRef) {
1308 return redirect(
1309 `/${repo.name}/branches?error=${encodeURIComponent("Source ref is required.")}`,
1310 );
1311 }
1312
1313 const result = await git.createBranch(repo.name, name, sourceRef);
1314 if (result === "ok") {
1315 return redirect(
1316 `/${repo.name}/branches?success=${encodeURIComponent(`Branch "${name}" created.`)}`,
1317 );
1318 }
1319 if (result === "already_exists") {
1320 return redirect(
1321 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${name}" already exists.`)}`,
1322 );
1323 }
1324 if (result === "bad_ref") {
1325 return redirect(
1326 `/${repo.name}/branches?error=${encodeURIComponent(`"${sourceRef}" is not a valid ref.`)}`,
1327 );
1328 }
1329 return redirect(
1330 `/${repo.name}/branches?error=${encodeURIComponent("Failed to create branch.")}`,
1331 );
1332 },
1333 {
1334 body: t.Object({
1335 name: t.String(),
1336 source_ref: t.String(),
1337 }),
1338 },
1339 )
1340
1341 .post(
1342 "/:repo/branches/delete",
1343 async ({ params, body, cookie }) => {
1344 const user = await resolveSession(cookie.session.value);
1345 const deny = requireAdmin(user);
1346 if (deny) return deny;
1347 const repo = await getRepo(params.repo, true);
1348 if (!repo) return new Response("Not found", { status: 404 });
1349
1350 const name = body.name?.trim() ?? "";
1351 if (!name) {
1352 return redirect(
1353 `/${repo.name}/branches?error=${encodeURIComponent("Branch name is required.")}`,
1354 );
1355 }
1356 if (name === repo.default_branch) {
1357 return redirect(
1358 `/${repo.name}/branches?error=${encodeURIComponent("Cannot delete the default branch.")}`,
1359 );
1360 }
1361
1362 const result = await git.deleteBranch(repo.name, name);
1363 if (result === "ok") {
1364 return redirect(
1365 `/${repo.name}/branches?success=${encodeURIComponent(`Branch "${name}" deleted.`)}`,
1366 );
1367 }
1368 if (result === "not_found") {
1369 return redirect(
1370 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${name}" not found.`)}`,
1371 );
1372 }
1373 return redirect(
1374 `/${repo.name}/branches?error=${encodeURIComponent("Failed to delete branch.")}`,
1375 );
1376 },
1377 {
1378 body: t.Object({ name: t.String() }),
1379 },
1380 )
1381
1382 .post(
1383 "/:repo/branches/rename",
1384 async ({ params, body, cookie }) => {
1385 const user = await resolveSession(cookie.session.value);
1386 const deny = requireAdmin(user);
1387 if (deny) return deny;
1388 const repo = await getRepo(params.repo, true);
1389 if (!repo) return new Response("Not found", { status: 404 });
1390
1391 const oldName = body.old_name?.trim() ?? "";
1392 const newName = body.new_name?.trim() ?? "";
1393
1394 if (
1395 !newName ||
1396 !/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(newName) ||
1397 newName.includes("..") ||
1398 newName.length > MAX_BRANCH_NAME_LENGTH
1399 ) {
1400 return redirect(
1401 `/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
1402 );
1403 }
1404
1405 const result = await git.renameBranch(repo.name, oldName, newName);
1406 if (result === "ok") {
1407 // Keep default_branch in DB in sync if we renamed it
1408 if (oldName === repo.default_branch) {
1409 await db
1410 .updateTable("repositories")
1411 .set({ default_branch: newName })
1412 .where("id", "=", repo.id)
1413 .execute();
1414 await git
1415 .setHead(repo.name, newName)
1416 .catch((e) =>
1417 console.error(
1418 `setHead failed for ${repo.name}/${newName}:`,
1419 e,
1420 ),
1421 );
1422 }
1423 return redirect(
1424 `/${repo.name}/branches?success=${encodeURIComponent(`Branch renamed to "${newName}".`)}`,
1425 );
1426 }
1427 if (result === "not_found") {
1428 return redirect(
1429 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${oldName}" not found.`)}`,
1430 );
1431 }
1432 if (result === "already_exists") {
1433 return redirect(
1434 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${newName}" already exists.`)}`,
1435 );
1436 }
1437 return redirect(
1438 `/${repo.name}/branches?error=${encodeURIComponent("Failed to rename branch.")}`,
1439 );
1440 },
1441 {
1442 body: t.Object({
1443 old_name: t.String(),
1444 new_name: t.String(),
1445 }),
1446 },
1447 )
1448
1449 // ── Tags ──────────────────────────────────────────────────────────────────
1450
1451 .get("/:repo/tags", async ({ params, query, cookie }) => {
1452 const user = await resolveSession(cookie.session.value);
1453 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
1454 if (!repo) return new Response("Not found", { status: 404 });
1455 const [allTags, releases] = await Promise.all([
1456 git.tagsWithInfo(repo.name),
1457 db
1458 .selectFrom("releases")
1459 .select(["id", "tag_name"])
1460 .where("repo_id", "=", repo.id)
1461 .where("tag_name", "is not", null)
1462 .execute(),
1463 ]);
1464 const tagReleaseMap = new Map<string, number>();
1465 for (const r of releases) {
1466 if (r.tag_name) tagReleaseMap.set(r.tag_name, r.id);
1467 }
1468 const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
1469 const totalPages = Math.max(
1470 1,
1471 Math.ceil(allTags.length / TAGS_PER_PAGE),
1472 );
1473 const safePage = Math.min(page, totalPages);
1474 const tags = allTags.slice(
1475 (safePage - 1) * TAGS_PER_PAGE,
1476 safePage * TAGS_PER_PAGE,
1477 );
1478 const success =
1479 typeof query.success === "string" ? query.success : undefined;
1480 const error = typeof query.error === "string" ? query.error : undefined;
1481 return html(
1482 <TagList
1483 user={user}
1484 repo={repo}
1485 tags={tags}
1486 tagReleaseMap={tagReleaseMap}
1487 page={safePage}
1488 totalPages={totalPages}
1489 success={success}
1490 error={error}
1491 />,
1492 );
1493 })
1494
1495 .post(
1496 "/:repo/tags/create",
1497 async ({ params, body, cookie }) => {
1498 const user = await resolveSession(cookie.session.value);
1499 const deny = requireAdmin(user);
1500 if (deny) return deny;
1501 const repo = await getRepo(params.repo, true);
1502 if (!repo) return new Response("Not found", { status: 404 });
1503
1504 const tagName = body.name?.trim() ?? "";
1505 const ref = body.ref?.trim() ?? "";
1506 const message = body.message?.trim() || undefined;
1507
1508 if (!tagName || !/^[a-zA-Z0-9._\-+]+$/.test(tagName)) {
1509 return redirect(
1510 `/${repo.name}/tags?error=${encodeURIComponent("Invalid tag name.")}`,
1511 );
1512 }
1513 if (!ref) {
1514 return redirect(
1515 `/${repo.name}/tags?error=${encodeURIComponent("Target ref is required.")}`,
1516 );
1517 }
1518
1519 const result = await git.createTag(
1520 repo.name,
1521 tagName,
1522 ref,
1523 message,
1524 message ? config.COMMITTER_NAME : undefined,
1525 message ? config.COMMITTER_EMAIL : undefined,
1526 );
1527 if (result === "ok") {
1528 return redirect(
1529 `/${repo.name}/tags?success=${encodeURIComponent(`Tag "${tagName}" created.`)}`,
1530 );
1531 }
1532 if (result === "already_exists") {
1533 return redirect(
1534 `/${repo.name}/tags?error=${encodeURIComponent(`Tag "${tagName}" already exists.`)}`,
1535 );
1536 }
1537 if (result === "bad_ref") {
1538 return redirect(
1539 `/${repo.name}/tags?error=${encodeURIComponent(`"${ref}" is not a valid ref.`)}`,
1540 );
1541 }
1542 return redirect(
1543 `/${repo.name}/tags?error=${encodeURIComponent("Failed to create tag.")}`,
1544 );
1545 },
1546 {
1547 body: t.Object({
1548 name: t.String(),
1549 ref: t.String(),
1550 message: t.Optional(t.String()),
1551 }),
1552 },
1553 )
1554
1555 .post(
1556 "/:repo/tags/delete",
1557 async ({ params, body, cookie }) => {
1558 const user = await resolveSession(cookie.session.value);
1559 const deny = requireAdmin(user);
1560 if (deny) return deny;
1561 const repo = await getRepo(params.repo, true);
1562 if (!repo) return new Response("Not found", { status: 404 });
1563
1564 const tagName = body.name?.trim() ?? "";
1565 if (!tagName) {
1566 return redirect(
1567 `/${repo.name}/tags?error=${encodeURIComponent("Tag name is required.")}`,
1568 );
1569 }
1570
1571 const result = await git.deleteTag(repo.name, tagName);
1572 if (result === "ok") {
1573 return redirect(
1574 `/${repo.name}/tags?success=${encodeURIComponent(`Tag "${tagName}" deleted.`)}`,
1575 );
1576 }
1577 if (result === "not_found") {
1578 return redirect(
1579 `/${repo.name}/tags?error=${encodeURIComponent(`Tag "${tagName}" not found.`)}`,
1580 );
1581 }
1582 return redirect(
1583 `/${repo.name}/tags?error=${encodeURIComponent("Failed to delete tag.")}`,
1584 );
1585 },
1586 {
1587 body: t.Object({ name: t.String() }),
1588 },
1589 )
1590
1591 // ── File creation ─────────────────────────────────────────────────────────
1592
1593 .get("/:repo/new-file/:ref", async ({ params, query, cookie }) => {
1594 const user = await resolveSession(cookie.session.value);
1595 const deny = requireAdmin(user);
1596 if (deny) return deny;
1597 const repo = await getRepo(params.repo, true);
1598 if (!repo) return new Response("Not found", { status: 404 });
1599 const dir = typeof query.dir === "string" ? query.dir : "";
1600 const error = typeof query.error === "string" ? query.error : undefined;
1601 return html(
1602 <NewFileForm
1603 user={user!}
1604 repo={repo}
1605 ref={params.ref}
1606 dir={dir}
1607 error={error}
1608 />,
1609 );
1610 })
1611
1612 .post(
1613 "/:repo/new-file/:ref",
1614 async ({ params, body, cookie }) => {
1615 const user = await resolveSession(cookie.session.value);
1616 const deny = requireAdmin(user);
1617 if (deny) return deny;
1618 const repo = await getRepo(params.repo, true);
1619 if (!repo) return new Response("Not found", { status: 404 });
1620
1621 const filePath = body.path?.trim() ?? "";
1622 const content = body.content ?? "";
1623 const message = body.message?.trim() || `Add ${filePath}`;
1624
1625 if (
1626 !filePath ||
1627 filePath.startsWith("/") ||
1628 filePath.includes("..") ||
1629 filePath.includes("\0")
1630 ) {
1631 return redirect(
1632 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Invalid file path.")}`,
1633 );
1634 }
1635
1636 // Ensure we're on a branch
1637 const branches = await git.branches(repo.name);
1638 if (branches.length > 0 && !branches.includes(params.ref)) {
1639 return redirect(
1640 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Can only create files on a branch.")}`,
1641 );
1642 }
1643
1644 try {
1645 const commit = await git.createFile(
1646 repo.name,
1647 params.ref,
1648 filePath,
1649 content,
1650 message,
1651 config.COMMITTER_NAME,
1652 config.COMMITTER_EMAIL,
1653 );
1654 return redirect(`/${repo.name}/commit/${commit}`);
1655 } catch {
1656 return redirect(
1657 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Failed to create file.")}`,
1658 );
1659 }
1660 },
1661 {
1662 body: t.Object({
1663 path: t.String(),
1664 content: t.Optional(t.String()),
1665 message: t.Optional(t.String()),
1666 }),
1667 },
1668 )
1669
1670 // ── File deletion ─────────────────────────────────────────────────────────
1671
1672 .post(
1673 "/:repo/delete-file/:ref/*",
1674 async ({ params, body, cookie }) => {
1675 const user = await resolveSession(cookie.session.value);
1676 const deny = requireAdmin(user);
1677 if (deny) return deny;
1678 const repo = await getRepo(params.repo, true);
1679 if (!repo) return new Response("Not found", { status: 404 });
1680
1681 const filePath = decodeURIComponent(params["*"]);
1682 const message = body.message?.trim() || `Delete ${filePath}`;
1683
1684 try {
1685 const commit = await git.deleteFile(
1686 repo.name,
1687 params.ref,
1688 filePath,
1689 message,
1690 config.COMMITTER_NAME,
1691 config.COMMITTER_EMAIL,
1692 );
1693 return redirect(`/${repo.name}/commit/${commit}`);
1694 } catch {
1695 return redirect(
1696 `/${repo.name}/blob/${params.ref}/${filePath}?error=${encodeURIComponent("Failed to delete file.")}`,
1697 );
1698 }
1699 },
1700 {
1701 body: t.Object({
1702 message: t.Optional(t.String()),
1703 }),
1704 },
1705 );
1706