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