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