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