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