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 back = (msg: string) =>
1077 redirect(
1078 `/${oldName}/settings?error=${encodeURIComponent(msg)}`,
1079 );
1080
1081 if (newName === oldName) {
1082 return back("New name is the same as the current name.");
1083 }
1084 if (newName.toLowerCase() === oldName.toLowerCase()) {
1085 return back("Case-only renames are not supported.");
1086 }
1087 if (!VALID_REPO_NAME_RE.test(newName)) {
1088 return back("Invalid repository name.");
1089 }
1090
1091 const clash = await db
1092 .selectFrom("repositories")
1093 .select("id")
1094 .where("name", "=", newName)
1095 .executeTakeFirst();
1096 if (clash) return back("Repository name already taken.");
1097
1098 const fromPath = repoPath(oldName);
1099 const toPath = repoPath(newName);
1100 if (existsSync(toPath)) {
1101 return back(
1102 "A directory for that name already exists on disk.",
1103 );
1104 }
1105
1106 try {
1107 renameSync(fromPath, toPath);
1108 } catch (err) {
1109 console.error(
1110 `rename ${fromPath} -> ${toPath} failed`,
1111 err,
1112 );
1113 return back("Failed to rename repository on disk.");
1114 }
1115
1116 try {
1117 await db
1118 .updateTable("repositories")
1119 .set({ name: newName })
1120 .where("id", "=", repo.id)
1121 .execute();
1122 } catch (err) {
1123 console.error("db rename failed; rolling back disk", err);
1124 try {
1125 renameSync(toPath, fromPath);
1126 } catch (rb) {
1127 console.error("rollback rename failed", rb);
1128 }
1129 return back("Failed to update repository record.");
1130 }
1131
1132 invalidateRefCache(oldName);
1133 return redirect(
1134 `/${newName}/settings?success=${encodeURIComponent("Repository renamed.")}`,
1135 );
1136 },
1137 {
1138 body: t.Object({
1139 new_name: t.String(),
1140 }),
1141 },
1142 )
1143
1144 .post(
1145 "/:repo/settings/labels",
1146 async ({ params, body, cookie }) => {
1147 const user = await resolveSession(cookie.session.value);
1148 const deny = requireAdmin(user);
1149 if (deny) return deny;
1150 const repo = await getRepo(params.repo, true);
1151 if (!repo) return new Response("Not found", { status: 404 });
1152
1153 const name = body.name?.trim();
1154 const color = body.color?.trim();
1155
1156 if (!name || name.length > MAX_LABEL_NAME_LENGTH) {
1157 return redirect(
1158 `/${repo.name}/settings?error=${encodeURIComponent("Label name must be 1–50 characters.")}`,
1159 );
1160 }
1161 if (!color || !/^#[0-9a-fA-F]{6}$/.test(color)) {
1162 return redirect(
1163 `/${repo.name}/settings?error=${encodeURIComponent("Invalid color.")}`,
1164 );
1165 }
1166
1167 try {
1168 await db
1169 .insertInto("labels")
1170 .values({
1171 repo_id: repo.id,
1172 name,
1173 color,
1174 created_at: new Date().toISOString(),
1175 })
1176 .execute();
1177 } catch {
1178 return redirect(
1179 `/${repo.name}/settings?error=${encodeURIComponent("A label with that name already exists.")}`,
1180 );
1181 }
1182
1183 return redirect(`/${repo.name}/settings?success=Label+created.`);
1184 },
1185 {
1186 body: t.Object({
1187 name: t.String(),
1188 color: t.String(),
1189 }),
1190 },
1191 )
1192
1193 .post(
1194 "/:repo/settings/labels/delete",
1195 async ({ params, body, cookie }) => {
1196 const user = await resolveSession(cookie.session.value);
1197 const deny = requireAdmin(user);
1198 if (deny) return deny;
1199 const repo = await getRepo(params.repo, true);
1200 if (!repo) return new Response("Not found", { status: 404 });
1201
1202 const label = await db
1203 .selectFrom("labels")
1204 .select(["id", "repo_id"])
1205 .where("id", "=", body.id)
1206 .executeTakeFirst();
1207
1208 if (!label || label.repo_id !== repo.id) {
1209 return redirect(
1210 `/${repo.name}/settings?error=${encodeURIComponent("Label not found.")}`,
1211 );
1212 }
1213
1214 await db.deleteFrom("labels").where("id", "=", body.id).execute();
1215
1216 return redirect(`/${repo.name}/settings?success=Label+deleted.`);
1217 },
1218 {
1219 body: t.Object({ id: t.Numeric() }),
1220 },
1221 )
1222
1223 // ── Branches ──────────────────────────────────────────────────────────────
1224
1225 .get("/:repo/branches", async ({ params, query, cookie }) => {
1226 const user = await resolveSession(cookie.session.value);
1227 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
1228 if (!repo) return new Response("Not found", { status: 404 });
1229 const allBranches = await git.branchesWithInfo(repo.name);
1230 const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
1231 const totalPages = Math.max(
1232 1,
1233 Math.ceil(allBranches.length / BRANCHES_PER_PAGE),
1234 );
1235 const safePage = Math.min(page, totalPages);
1236 const branches = allBranches.slice(
1237 (safePage - 1) * BRANCHES_PER_PAGE,
1238 safePage * BRANCHES_PER_PAGE,
1239 );
1240 const success =
1241 typeof query.success === "string" ? query.success : undefined;
1242 const error = typeof query.error === "string" ? query.error : undefined;
1243 return html(
1244 <BranchList
1245 user={user}
1246 repo={repo}
1247 branches={branches}
1248 page={safePage}
1249 totalPages={totalPages}
1250 success={success}
1251 error={error}
1252 />,
1253 );
1254 })
1255
1256 .post(
1257 "/:repo/branches/create",
1258 async ({ params, body, cookie }) => {
1259 const user = await resolveSession(cookie.session.value);
1260 const deny = requireAdmin(user);
1261 if (deny) return deny;
1262 const repo = await getRepo(params.repo, true);
1263 if (!repo) return new Response("Not found", { status: 404 });
1264
1265 const name = body.name?.trim() ?? "";
1266 const sourceRef = body.source_ref?.trim() ?? "";
1267
1268 if (
1269 !name ||
1270 !/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(name) ||
1271 name.includes("..") ||
1272 name.length > MAX_BRANCH_NAME_LENGTH
1273 ) {
1274 return redirect(
1275 `/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
1276 );
1277 }
1278 if (!sourceRef) {
1279 return redirect(
1280 `/${repo.name}/branches?error=${encodeURIComponent("Source ref is required.")}`,
1281 );
1282 }
1283
1284 const result = await git.createBranch(repo.name, name, sourceRef);
1285 if (result === "ok") {
1286 return redirect(
1287 `/${repo.name}/branches?success=${encodeURIComponent(`Branch "${name}" created.`)}`,
1288 );
1289 }
1290 if (result === "already_exists") {
1291 return redirect(
1292 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${name}" already exists.`)}`,
1293 );
1294 }
1295 if (result === "bad_ref") {
1296 return redirect(
1297 `/${repo.name}/branches?error=${encodeURIComponent(`"${sourceRef}" is not a valid ref.`)}`,
1298 );
1299 }
1300 return redirect(
1301 `/${repo.name}/branches?error=${encodeURIComponent("Failed to create branch.")}`,
1302 );
1303 },
1304 {
1305 body: t.Object({
1306 name: t.String(),
1307 source_ref: t.String(),
1308 }),
1309 },
1310 )
1311
1312 .post(
1313 "/:repo/branches/delete",
1314 async ({ params, body, cookie }) => {
1315 const user = await resolveSession(cookie.session.value);
1316 const deny = requireAdmin(user);
1317 if (deny) return deny;
1318 const repo = await getRepo(params.repo, true);
1319 if (!repo) return new Response("Not found", { status: 404 });
1320
1321 const name = body.name?.trim() ?? "";
1322 if (!name) {
1323 return redirect(
1324 `/${repo.name}/branches?error=${encodeURIComponent("Branch name is required.")}`,
1325 );
1326 }
1327 if (name === repo.default_branch) {
1328 return redirect(
1329 `/${repo.name}/branches?error=${encodeURIComponent("Cannot delete the default branch.")}`,
1330 );
1331 }
1332
1333 const result = await git.deleteBranch(repo.name, name);
1334 if (result === "ok") {
1335 return redirect(
1336 `/${repo.name}/branches?success=${encodeURIComponent(`Branch "${name}" deleted.`)}`,
1337 );
1338 }
1339 if (result === "not_found") {
1340 return redirect(
1341 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${name}" not found.`)}`,
1342 );
1343 }
1344 return redirect(
1345 `/${repo.name}/branches?error=${encodeURIComponent("Failed to delete branch.")}`,
1346 );
1347 },
1348 {
1349 body: t.Object({ name: t.String() }),
1350 },
1351 )
1352
1353 .post(
1354 "/:repo/branches/rename",
1355 async ({ params, body, cookie }) => {
1356 const user = await resolveSession(cookie.session.value);
1357 const deny = requireAdmin(user);
1358 if (deny) return deny;
1359 const repo = await getRepo(params.repo, true);
1360 if (!repo) return new Response("Not found", { status: 404 });
1361
1362 const oldName = body.old_name?.trim() ?? "";
1363 const newName = body.new_name?.trim() ?? "";
1364
1365 if (
1366 !newName ||
1367 !/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(newName) ||
1368 newName.includes("..") ||
1369 newName.length > MAX_BRANCH_NAME_LENGTH
1370 ) {
1371 return redirect(
1372 `/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
1373 );
1374 }
1375
1376 const result = await git.renameBranch(repo.name, oldName, newName);
1377 if (result === "ok") {
1378 // Keep default_branch in DB in sync if we renamed it
1379 if (oldName === repo.default_branch) {
1380 await db
1381 .updateTable("repositories")
1382 .set({ default_branch: newName })
1383 .where("id", "=", repo.id)
1384 .execute();
1385 await git
1386 .setHead(repo.name, newName)
1387 .catch((e) =>
1388 console.error(
1389 `setHead failed for ${repo.name}/${newName}:`,
1390 e,
1391 ),
1392 );
1393 }
1394 return redirect(
1395 `/${repo.name}/branches?success=${encodeURIComponent(`Branch renamed to "${newName}".`)}`,
1396 );
1397 }
1398 if (result === "not_found") {
1399 return redirect(
1400 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${oldName}" not found.`)}`,
1401 );
1402 }
1403 if (result === "already_exists") {
1404 return redirect(
1405 `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${newName}" already exists.`)}`,
1406 );
1407 }
1408 return redirect(
1409 `/${repo.name}/branches?error=${encodeURIComponent("Failed to rename branch.")}`,
1410 );
1411 },
1412 {
1413 body: t.Object({
1414 old_name: t.String(),
1415 new_name: t.String(),
1416 }),
1417 },
1418 )
1419
1420 // ── Tags ──────────────────────────────────────────────────────────────────
1421
1422 .get("/:repo/tags", async ({ params, query, cookie }) => {
1423 const user = await resolveSession(cookie.session.value);
1424 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
1425 if (!repo) return new Response("Not found", { status: 404 });
1426 const [allTags, releases] = await Promise.all([
1427 git.tagsWithInfo(repo.name),
1428 db
1429 .selectFrom("releases")
1430 .select(["id", "tag_name"])
1431 .where("repo_id", "=", repo.id)
1432 .where("tag_name", "is not", null)
1433 .execute(),
1434 ]);
1435 const tagReleaseMap = new Map<string, number>();
1436 for (const r of releases) {
1437 if (r.tag_name) tagReleaseMap.set(r.tag_name, r.id);
1438 }
1439 const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
1440 const totalPages = Math.max(
1441 1,
1442 Math.ceil(allTags.length / TAGS_PER_PAGE),
1443 );
1444 const safePage = Math.min(page, totalPages);
1445 const tags = allTags.slice(
1446 (safePage - 1) * TAGS_PER_PAGE,
1447 safePage * TAGS_PER_PAGE,
1448 );
1449 const success =
1450 typeof query.success === "string" ? query.success : undefined;
1451 const error = typeof query.error === "string" ? query.error : undefined;
1452 return html(
1453 <TagList
1454 user={user}
1455 repo={repo}
1456 tags={tags}
1457 tagReleaseMap={tagReleaseMap}
1458 page={safePage}
1459 totalPages={totalPages}
1460 success={success}
1461 error={error}
1462 />,
1463 );
1464 })
1465
1466 .post(
1467 "/:repo/tags/create",
1468 async ({ params, body, cookie }) => {
1469 const user = await resolveSession(cookie.session.value);
1470 const deny = requireAdmin(user);
1471 if (deny) return deny;
1472 const repo = await getRepo(params.repo, true);
1473 if (!repo) return new Response("Not found", { status: 404 });
1474
1475 const tagName = body.name?.trim() ?? "";
1476 const ref = body.ref?.trim() ?? "";
1477 const message = body.message?.trim() || undefined;
1478
1479 if (!tagName || !/^[a-zA-Z0-9._\-+]+$/.test(tagName)) {
1480 return redirect(
1481 `/${repo.name}/tags?error=${encodeURIComponent("Invalid tag name.")}`,
1482 );
1483 }
1484 if (!ref) {
1485 return redirect(
1486 `/${repo.name}/tags?error=${encodeURIComponent("Target ref is required.")}`,
1487 );
1488 }
1489
1490 const result = await git.createTag(
1491 repo.name,
1492 tagName,
1493 ref,
1494 message,
1495 message ? config.COMMITTER_NAME : undefined,
1496 message ? config.COMMITTER_EMAIL : undefined,
1497 );
1498 if (result === "ok") {
1499 return redirect(
1500 `/${repo.name}/tags?success=${encodeURIComponent(`Tag "${tagName}" created.`)}`,
1501 );
1502 }
1503 if (result === "already_exists") {
1504 return redirect(
1505 `/${repo.name}/tags?error=${encodeURIComponent(`Tag "${tagName}" already exists.`)}`,
1506 );
1507 }
1508 if (result === "bad_ref") {
1509 return redirect(
1510 `/${repo.name}/tags?error=${encodeURIComponent(`"${ref}" is not a valid ref.`)}`,
1511 );
1512 }
1513 return redirect(
1514 `/${repo.name}/tags?error=${encodeURIComponent("Failed to create tag.")}`,
1515 );
1516 },
1517 {
1518 body: t.Object({
1519 name: t.String(),
1520 ref: t.String(),
1521 message: t.Optional(t.String()),
1522 }),
1523 },
1524 )
1525
1526 .post(
1527 "/:repo/tags/delete",
1528 async ({ params, body, cookie }) => {
1529 const user = await resolveSession(cookie.session.value);
1530 const deny = requireAdmin(user);
1531 if (deny) return deny;
1532 const repo = await getRepo(params.repo, true);
1533 if (!repo) return new Response("Not found", { status: 404 });
1534
1535 const tagName = body.name?.trim() ?? "";
1536 if (!tagName) {
1537 return redirect(
1538 `/${repo.name}/tags?error=${encodeURIComponent("Tag name is required.")}`,
1539 );
1540 }
1541
1542 const result = await git.deleteTag(repo.name, tagName);
1543 if (result === "ok") {
1544 return redirect(
1545 `/${repo.name}/tags?success=${encodeURIComponent(`Tag "${tagName}" deleted.`)}`,
1546 );
1547 }
1548 if (result === "not_found") {
1549 return redirect(
1550 `/${repo.name}/tags?error=${encodeURIComponent(`Tag "${tagName}" not found.`)}`,
1551 );
1552 }
1553 return redirect(
1554 `/${repo.name}/tags?error=${encodeURIComponent("Failed to delete tag.")}`,
1555 );
1556 },
1557 {
1558 body: t.Object({ name: t.String() }),
1559 },
1560 )
1561
1562 // ── File creation ─────────────────────────────────────────────────────────
1563
1564 .get("/:repo/new-file/:ref", async ({ params, query, cookie }) => {
1565 const user = await resolveSession(cookie.session.value);
1566 const deny = requireAdmin(user);
1567 if (deny) return deny;
1568 const repo = await getRepo(params.repo, true);
1569 if (!repo) return new Response("Not found", { status: 404 });
1570 const dir = typeof query.dir === "string" ? query.dir : "";
1571 const error = typeof query.error === "string" ? query.error : undefined;
1572 return html(
1573 <NewFileForm
1574 user={user!}
1575 repo={repo}
1576 ref={params.ref}
1577 dir={dir}
1578 error={error}
1579 />,
1580 );
1581 })
1582
1583 .post(
1584 "/:repo/new-file/:ref",
1585 async ({ params, body, cookie }) => {
1586 const user = await resolveSession(cookie.session.value);
1587 const deny = requireAdmin(user);
1588 if (deny) return deny;
1589 const repo = await getRepo(params.repo, true);
1590 if (!repo) return new Response("Not found", { status: 404 });
1591
1592 const filePath = body.path?.trim() ?? "";
1593 const content = body.content ?? "";
1594 const message = body.message?.trim() || `Add ${filePath}`;
1595
1596 if (
1597 !filePath ||
1598 filePath.startsWith("/") ||
1599 filePath.includes("..") ||
1600 filePath.includes("\0")
1601 ) {
1602 return redirect(
1603 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Invalid file path.")}`,
1604 );
1605 }
1606
1607 // Ensure we're on a branch
1608 const branches = await git.branches(repo.name);
1609 if (branches.length > 0 && !branches.includes(params.ref)) {
1610 return redirect(
1611 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Can only create files on a branch.")}`,
1612 );
1613 }
1614
1615 try {
1616 const commit = await git.createFile(
1617 repo.name,
1618 params.ref,
1619 filePath,
1620 content,
1621 message,
1622 config.COMMITTER_NAME,
1623 config.COMMITTER_EMAIL,
1624 );
1625 return redirect(`/${repo.name}/commit/${commit}`);
1626 } catch {
1627 return redirect(
1628 `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Failed to create file.")}`,
1629 );
1630 }
1631 },
1632 {
1633 body: t.Object({
1634 path: t.String(),
1635 content: t.Optional(t.String()),
1636 message: t.Optional(t.String()),
1637 }),
1638 },
1639 )
1640
1641 // ── File deletion ─────────────────────────────────────────────────────────
1642
1643 .post(
1644 "/:repo/delete-file/:ref/*",
1645 async ({ params, body, cookie }) => {
1646 const user = await resolveSession(cookie.session.value);
1647 const deny = requireAdmin(user);
1648 if (deny) return deny;
1649 const repo = await getRepo(params.repo, true);
1650 if (!repo) return new Response("Not found", { status: 404 });
1651
1652 const filePath = decodeURIComponent(params["*"]);
1653 const message = body.message?.trim() || `Delete ${filePath}`;
1654
1655 try {
1656 const commit = await git.deleteFile(
1657 repo.name,
1658 params.ref,
1659 filePath,
1660 message,
1661 config.COMMITTER_NAME,
1662 config.COMMITTER_EMAIL,
1663 );
1664 return redirect(`/${repo.name}/commit/${commit}`);
1665 } catch {
1666 return redirect(
1667 `/${repo.name}/blob/${params.ref}/${filePath}?error=${encodeURIComponent("Failed to delete file.")}`,
1668 );
1669 }
1670 },
1671 {
1672 body: t.Object({
1673 message: t.Optional(t.String()),
1674 }),
1675 },
1676 );
1677