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 { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
6import {
7 ALLOWED_SIGNERS_PATH,
8 COMMITS_PER_PAGE,
9 REPOS_PER_PAGE,
10 VALID_REPO_NAME_RE,
11} from "../constants.ts";
12import { db } from "../db/index.ts";
13import { redirect } from "../lib/redirect.ts";
14import { requireAdmin, resolveSession } from "../middleware/session.ts";
15import { git, repoPath } from "../services/git.ts";
16import {
17 hasBinaryContent,
18 prepareDiff,
19 serveFile,
20} from "../services/highlightWorker.ts";
21import { renderMarkdown } from "../services/markdown.ts";
22import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
23import { html } from "../views/render.tsx";
24import { CommitDetail } from "../views/repos/CommitDetail.tsx";
25import { CommitLog } from "../views/repos/CommitLog.tsx";
26import { FileBlob } from "../views/repos/FileBlob.tsx";
27import { FileEdit } from "../views/repos/FileEdit.tsx";
28import { FileTree } from "../views/repos/FileTree.tsx";
29import { NewRepo } from "../views/repos/NewRepo.tsx";
30import { RepoHome } from "../views/repos/RepoHome.tsx";
31import { RepoList } from "../views/repos/RepoList.tsx";
32import { RepoSettings } from "../views/repos/RepoSettings.tsx";
33
34async function getRepo(name: string, isAdmin: boolean) {
35 if (!repoDiskExists(name)) return null;
36 const repo = await ensureRepoRecord(name);
37 if (repo.is_private && !isAdmin) return null;
38 return repo;
39}
40
41async function mimeForContent(
42 filename: string,
43 content: Buffer,
44): Promise<string> {
45 const result = await fileTypeFromBuffer(content);
46 if (result) return result.mime;
47
48 const typeFromName = Bun.file(filename).type;
49 if (typeFromName !== "application/octet-stream") {
50 return typeFromName;
51 }
52
53 return hasBinaryContent(content.subarray(0, 8000))
54 ? "application/octet-stream"
55 : "text/plain; charset=utf-8";
56}
57
58async function readReadme(
59 repo: string,
60 ref: string,
61 dir = "",
62): Promise<Buffer | null> {
63 const prefix = dir ? `${dir}/` : "";
64 const [md, mdLc, readme, readmeLc] = await Promise.all([
65 git.show(repo, ref, `${prefix}README.md`),
66 git.show(repo, ref, `${prefix}readme.md`),
67 git.show(repo, ref, `${prefix}README`),
68 git.show(repo, ref, `${prefix}readme`),
69 ]);
70 return md ?? mdLc ?? readme ?? readmeLc;
71}
72
73export const repoRoutes = new Elysia()
74 .get("/allowed_signers", () => {
75 return new Response(readFileSync(ALLOWED_SIGNERS_PATH), {
76 headers: { "Content-Type": "text/plain; charset=utf-8" },
77 });
78 })
79 .guard({
80 cookie: t.Cookie({
81 session: t.Optional(t.String()),
82 repo_sort: t.Optional(t.String()),
83 }),
84 })
85 .post(
86 "/sort",
87 ({ body }) => {
88 const sort = body.sort === "name" ? "name" : "created";
89 return redirect(
90 "/",
91 `repo_sort=${sort}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`,
92 );
93 },
94 { body: t.Object({ sort: t.String() }) },
95 )
96 .get(
97 "/",
98 async ({ cookie, query }) => {
99 const user = await resolveSession(cookie.session.value);
100 const search = query.q?.trim() || undefined;
101 const page = Math.max(1, query.page ?? 1);
102 const sort = cookie.repo_sort.value === "name" ? "name" : "created";
103
104 const isAdmin = user?.isAdmin ?? false;
105
106 const countResult = await db
107 .selectFrom("repositories")
108 .select(db.fn.countAll<number>().as("count"))
109 .where((eb) =>
110 isAdmin
111 ? eb.or([
112 eb("is_private", "=", 0),
113 eb("is_private", "=", 1),
114 ])
115 : eb("is_private", "=", 0),
116 )
117 .$if(!!search, (qb) =>
118 qb.where((eb) =>
119 eb.or([
120 eb("name", "like", `%${search}%`),
121 eb("description", "like", `%${search}%`),
122 ]),
123 ),
124 )
125 .executeTakeFirst();
126
127 const totalCount = Number(countResult?.count ?? 0);
128 const totalPages = Math.max(
129 1,
130 Math.ceil(totalCount / REPOS_PER_PAGE),
131 );
132 const safePage = Math.min(page, totalPages);
133
134 const repos = await db
135 .selectFrom("repositories")
136 .selectAll()
137 .where((eb) =>
138 isAdmin
139 ? eb.or([
140 eb("is_private", "=", 0),
141 eb("is_private", "=", 1),
142 ])
143 : eb("is_private", "=", 0),
144 )
145 .$if(!!search, (qb) =>
146 qb.where((eb) =>
147 eb.or([
148 eb("name", "like", `%${search}%`),
149 eb("description", "like", `%${search}%`),
150 ]),
151 ),
152 )
153 .orderBy("is_pinned", "desc")
154 .$if(sort === "name", (qb) => qb.orderBy("name", "asc"))
155 .$if(sort === "created", (qb) =>
156 qb.orderBy("created_at", "desc"),
157 )
158 .limit(REPOS_PER_PAGE)
159 .offset((safePage - 1) * REPOS_PER_PAGE)
160 .execute();
161
162 const searchParam = search
163 ? `&q=${encodeURIComponent(search)}`
164 : "";
165 const pagination = {
166 page: safePage,
167 totalPages,
168 pageUrlTemplate: `/?page={page}${searchParam}`,
169 };
170
171 return html(
172 <RepoList
173 user={user}
174 repos={repos}
175 search={search}
176 sort={sort}
177 pagination={pagination}
178 />,
179 );
180 },
181 {
182 query: t.Object({
183 q: t.Optional(t.String()),
184 page: t.Optional(t.Numeric()),
185 }),
186 },
187 )
188
189 .get("/new", async ({ cookie }) => {
190 const user = await resolveSession(cookie.session.value);
191 const deny = requireAdmin(user);
192 if (deny) return deny;
193 return html(<NewRepo user={user!} />);
194 })
195
196 .post(
197 "/new",
198 async ({ body, cookie }) => {
199 const user = await resolveSession(cookie.session.value);
200 const deny = requireAdmin(user);
201 if (deny) return deny;
202
203 const { name, description, is_private, default_branch } = body;
204
205 if (!VALID_REPO_NAME_RE.test(name)) {
206 return html(
207 <NewRepo user={user!} error="Invalid repository name" />,
208 );
209 }
210
211 const branch = (default_branch?.trim() || "main").replace(
212 /[^a-zA-Z0-9._/-]/g,
213 "",
214 );
215
216 const existing = await db
217 .selectFrom("repositories")
218 .select("id")
219 .where("name", "=", name)
220 .executeTakeFirst();
221 if (existing) {
222 return html(
223 <NewRepo
224 user={user!}
225 error="Repository name already taken"
226 />,
227 );
228 }
229
230 const now = new Date().toISOString();
231 await db
232 .insertInto("repositories")
233 .values({
234 name,
235 description: description || null,
236 is_private: is_private === "1" ? 1 : 0,
237 default_branch: branch,
238 created_at: now,
239 })
240 .execute();
241
242 // Initialise the git repo after the DB record is committed. If
243 // git.init fails we roll back the DB record so the two stay in sync.
244 try {
245 await git.init(name, branch);
246 } catch (err) {
247 await db
248 .deleteFrom("repositories")
249 .where("name", "=", name)
250 .execute();
251 throw err;
252 }
253 return new Response(null, {
254 status: 302,
255 headers: { Location: `/${name}` },
256 });
257 },
258 {
259 body: t.Object({
260 name: t.String(),
261 description: t.Optional(t.String()),
262 is_private: t.Optional(t.String()),
263 default_branch: t.Optional(t.String()),
264 }),
265 },
266 )
267
268 .get("/:repo", async ({ params, cookie }) => {
269 const user = await resolveSession(cookie.session.value);
270 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
271 if (!repo) return new Response("Not found", { status: 404 });
272
273 const hasContent = await git.hasCommits(repo.name);
274 let readmeHtml: string | null = null;
275 let entries: Awaited<ReturnType<typeof git.lsTree>> = [];
276 let branches: string[] = [];
277
278 if (hasContent) {
279 const [lsResult, branchResult, resolved] = await Promise.all([
280 git.lsTree(repo.name, repo.default_branch),
281 git.branches(repo.name),
282 git.resolveRef(repo.name, repo.default_branch),
283 ]);
284 entries = lsResult;
285 branches = branchResult;
286 const readmeBuf = await readReadme(repo.name, repo.default_branch);
287 if (readmeBuf) {
288 const key = resolved
289 ? `readme:${repo.name}:${resolved}:`
290 : undefined;
291 readmeHtml = renderMarkdown(readmeBuf.toString("utf-8"), key, {
292 repo: repo.name,
293 ref: repo.default_branch,
294 dir: "",
295 });
296 }
297 }
298
299 return html(
300 <RepoHome
301 user={user}
302 repo={repo}
303 entries={entries}
304 readmeHtml={readmeHtml}
305 hasContent={hasContent}
306 branches={branches}
307 />,
308 );
309 })
310
311 .get(
312 "/:repo/branch-switch",
313 async ({ params, query, cookie }) => {
314 const user = await resolveSession(cookie.session.value);
315 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
316 if (!repo) return new Response("Not found", { status: 404 });
317
318 const ref = query.rev?.trim();
319 if (!ref)
320 return new Response(null, {
321 status: 302,
322 headers: { Location: `/${repo.name}` },
323 });
324
325 const view = query.view;
326 const subpath = query.path ?? "";
327
328 if (view === "commits") {
329 return new Response(null, {
330 status: 302,
331 headers: { Location: `/${repo.name}/commits/${ref}` },
332 });
333 }
334 if (view === "blob" && subpath) {
335 return new Response(null, {
336 status: 302,
337 headers: {
338 Location: `/${repo.name}/blob/${ref}/${subpath}`,
339 },
340 });
341 }
342 const location = subpath
343 ? `/${repo.name}/tree/${ref}/${subpath}`
344 : `/${repo.name}/tree/${ref}`;
345 return new Response(null, {
346 status: 302,
347 headers: { Location: location },
348 });
349 },
350 {
351 query: t.Object({
352 rev: t.Optional(t.String()),
353 view: t.Optional(t.String()),
354 path: t.Optional(t.String()),
355 }),
356 },
357 )
358
359 .get("/:repo/tree/:ref", async ({ params, cookie }) => {
360 const user = await resolveSession(cookie.session.value);
361 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
362 if (!repo) return new Response("Not found", { status: 404 });
363
364 const resolved = await git.resolveRef(repo.name, params.ref);
365 if (!resolved) return new Response("Not found", { status: 404 });
366
367 const [entries, branches] = await Promise.all([
368 git.lsTree(repo.name, params.ref),
369 git.branches(repo.name),
370 ]);
371 const readmeBuf = await readReadme(repo.name, params.ref);
372 const readmeHtml = readmeBuf
373 ? renderMarkdown(
374 readmeBuf.toString("utf-8"),
375 `readme:${repo.name}:${resolved}:`,
376 { repo: repo.name, ref: params.ref, dir: "" },
377 )
378 : null;
379 return html(
380 <FileTree
381 user={user}
382 repo={repo}
383 ref={params.ref}
384 subpath=""
385 entries={entries}
386 branches={branches}
387 readmeHtml={readmeHtml}
388 />,
389 );
390 })
391
392 .get("/:repo/tree/:ref/*", async ({ params, cookie }) => {
393 const user = await resolveSession(cookie.session.value);
394 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
395 if (!repo) return new Response("Not found", { status: 404 });
396
397 const resolved = await git.resolveRef(repo.name, params.ref);
398 if (!resolved) return new Response("Not found", { status: 404 });
399
400 const subpath = decodeURIComponent(params["*"]);
401 const [entries, branches] = await Promise.all([
402 git.lsTree(repo.name, params.ref, subpath),
403 git.branches(repo.name),
404 ]);
405 if (entries.length === 0) {
406 // Could be a file — redirect to blob
407 return new Response(null, {
408 status: 302,
409 headers: {
410 Location: `/${repo.name}/blob/${params.ref}/${subpath}`,
411 },
412 });
413 }
414 const readmeBuf = await readReadme(repo.name, params.ref, subpath);
415 const readmeHtml = readmeBuf
416 ? renderMarkdown(
417 readmeBuf.toString("utf-8"),
418 `readme:${repo.name}:${resolved}:${subpath}`,
419 { repo: repo.name, ref: params.ref, dir: subpath },
420 )
421 : null;
422 return html(
423 <FileTree
424 user={user}
425 repo={repo}
426 ref={params.ref}
427 subpath={subpath}
428 entries={entries}
429 branches={branches}
430 readmeHtml={readmeHtml}
431 />,
432 );
433 })
434
435 .get("/:repo/blob/:ref/*", async ({ params, 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 filePath = decodeURIComponent(params["*"]);
441 const [content, branches, commitSHA] = await Promise.all([
442 git.show(repo.name, params.ref, filePath),
443 git.branches(repo.name),
444 git.resolveRef(repo.name, params.ref),
445 ]);
446 if (!content || !commitSHA)
447 return new Response("Not found", { status: 404 });
448
449 const filename = path.basename(filePath);
450 const view = await serveFile(
451 content,
452 filename,
453 `${repo.name}:${commitSHA}:${filePath}`,
454 );
455 return html(
456 <FileBlob
457 user={user}
458 repo={repo}
459 ref={params.ref}
460 filePath={filePath}
461 view={view}
462 branches={branches}
463 />,
464 );
465 })
466
467 .get("/:repo/raw/:ref/*", async ({ params, cookie, request }) => {
468 const user = await resolveSession(cookie.session.value);
469 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
470 if (!repo) return new Response("Not found", { status: 404 });
471
472 const filePath = decodeURIComponent(params["*"]);
473 const content = await git.show(repo.name, params.ref, filePath);
474 if (!content) return new Response("Not found", { status: 404 });
475
476 const filename = path.basename(filePath);
477 const contentType = await mimeForContent(filename, content);
478 const total = content.length;
479
480 const rangeHeader = request.headers.get("Range");
481 if (rangeHeader) {
482 const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
483 if (match) {
484 const start = match[1] ? parseInt(match[1], 10) : 0;
485 const end = match[2] ? parseInt(match[2], 10) : total - 1;
486 const clampedEnd = Math.min(end, total - 1);
487 return new Response(content.subarray(start, clampedEnd + 1), {
488 status: 206,
489 headers: {
490 "Content-Type": contentType,
491 "Content-Range": `bytes ${start}-${clampedEnd}/${total}`,
492 "Accept-Ranges": "bytes",
493 "Content-Length": String(clampedEnd - start + 1),
494 },
495 });
496 }
497 }
498
499 return new Response(content, {
500 headers: {
501 "Content-Type": contentType,
502 "Content-Disposition": `inline; filename="${filename}"`,
503 "Content-Length": String(total),
504 "Accept-Ranges": "bytes",
505 },
506 });
507 })
508
509 .get("/:repo/edit/:ref/*", async ({ params, cookie }) => {
510 const user = await resolveSession(cookie.session.value);
511 const deny = requireAdmin(user);
512 if (deny) return deny;
513 const repo = await getRepo(params.repo, true);
514 if (!repo) return new Response("Not found", { status: 404 });
515
516 const filePath = decodeURIComponent(params["*"]);
517 const branches = await git.branches(repo.name);
518 if (!branches.includes(params.ref))
519 return new Response("Not found", { status: 404 });
520
521 const content = await git.show(repo.name, params.ref, filePath);
522 if (!content) return new Response("Not found", { status: 404 });
523
524 if (hasBinaryContent(content.subarray(0, 8000)))
525 return new Response("Not found", { status: 404 });
526
527 return html(
528 <FileEdit
529 user={user!}
530 repo={repo}
531 ref={params.ref}
532 filePath={filePath}
533 content={content.toString("utf-8")}
534 />,
535 );
536 })
537
538 .post(
539 "/:repo/edit/:ref/*",
540 async ({ params, body, cookie }) => {
541 const user = await resolveSession(cookie.session.value);
542 const deny = requireAdmin(user);
543 if (deny) return deny;
544 const repo = await getRepo(params.repo, true);
545 if (!repo) return new Response("Not found", { status: 404 });
546
547 const filePath = decodeURIComponent(params["*"]);
548 const branches = await git.branches(repo.name);
549 if (!branches.includes(params.ref))
550 return new Response("Not found", { status: 404 });
551
552 const message =
553 body.message?.trim() || `Edited ${path.basename(filePath)}`;
554 const content = (body.content ?? "").replaceAll("\r\n", "\n");
555
556 const commit = await git.editFile(
557 repo.name,
558 params.ref,
559 filePath,
560 content,
561 message,
562 COMMITTER_NAME,
563 COMMITTER_EMAIL,
564 );
565
566 return new Response(null, {
567 status: 302,
568 headers: {
569 Location: `/${repo.name}/commit/${commit}`,
570 },
571 });
572 },
573 {
574 body: t.Object({
575 content: t.Optional(t.String()),
576 message: t.Optional(t.String()),
577 }),
578 },
579 )
580
581 .get(
582 "/:repo/commits/:ref",
583 async ({ params, cookie, query }) => {
584 const user = await resolveSession(cookie.session.value);
585 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
586 if (!repo) return new Response("Not found", { status: 404 });
587
588 // Cursor-based pagination — O(1) regardless of history depth.
589 // `after` = SHA of the last commit on the previous page (resume cursor).
590 // `prev` = the `after` value used on the page that linked here, so we can
591 // reconstruct a "← Newer" link without a full history traversal.
592 const after = query.after?.trim() || null;
593 const prev = query.prev?.trim() || null;
594
595 const [rawCommits, branches] = await Promise.all([
596 // When `after` is set: start at that SHA and skip it (--skip=1 is O(1)),
597 // then fetch LIMIT+1 to detect whether another page exists.
598 after
599 ? git.log(repo.name, after, COMMITS_PER_PAGE + 1, 1)
600 : git.log(repo.name, params.ref, COMMITS_PER_PAGE + 1, 0),
601 git.branches(repo.name),
602 ]);
603
604 const hasNext = rawCommits.length > COMMITS_PER_PAGE;
605 const commits = rawCommits.slice(0, COMMITS_PER_PAGE);
606
607 // Build cursor URLs.
608 // "Older" advances past the last commit on this page.
609 // "Newer" goes back one page using the `prev` cursor saved in the URL,
610 // or to the first page if we're on page 2.
611 const base = `/${repo.name}/commits/${params.ref}`;
612 const olderUrl = hasNext
613 ? `${base}?after=${commits[commits.length - 1]?.hash}&prev=${after ?? ""}`
614 : null;
615 const newerUrl = after
616 ? prev
617 ? `${base}?after=${prev}`
618 : base
619 : null;
620
621 return html(
622 <CommitLog
623 user={user}
624 repo={repo}
625 ref={params.ref}
626 commits={commits}
627 branches={branches}
628 olderUrl={olderUrl}
629 newerUrl={newerUrl}
630 />,
631 );
632 },
633 {
634 query: t.Object({
635 after: t.Optional(t.String()),
636 prev: t.Optional(t.String()),
637 }),
638 },
639 )
640
641 .get("/:repo/commit/:sha", async ({ params, cookie }) => {
642 const user = await resolveSession(cookie.session.value);
643 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
644 if (!repo) return new Response("Not found", { status: 404 });
645
646 const [meta, rawDiff] = await Promise.all([
647 git.commitMeta(repo.name, params.sha),
648 git.diff(repo.name, params.sha),
649 ]);
650 if (!meta) return new Response("Commit not found", { status: 404 });
651 const files = await prepareDiff(
652 rawDiff,
653 `commit:${repo.name}:${params.sha}`,
654 repo.name,
655 );
656 return html(
657 <CommitDetail
658 user={user}
659 repo={repo}
660 sha={params.sha}
661 meta={meta}
662 files={files}
663 />,
664 );
665 })
666
667 .get("/:repo/settings", async ({ params, cookie }) => {
668 const user = await resolveSession(cookie.session.value);
669 const deny = requireAdmin(user);
670 if (deny) return deny;
671 const repo = await getRepo(params.repo, true);
672 if (!repo) return new Response("Not found", { status: 404 });
673 const branches = await git.branches(repo.name);
674 return html(
675 <RepoSettings user={user!} repo={repo} branches={branches} />,
676 );
677 })
678
679 .post(
680 "/:repo/settings",
681 async ({ params, body, cookie }) => {
682 const user = await resolveSession(cookie.session.value);
683 const deny = requireAdmin(user);
684 if (deny) return deny;
685 const repo = await getRepo(params.repo, true);
686 if (!repo) return new Response("Not found", { status: 404 });
687
688 const {
689 description,
690 is_private,
691 is_pinned,
692 default_branch,
693 issue_template,
694 patch_template,
695 } = body;
696
697 const branches = await git.branches(repo.name);
698 const newBranch = default_branch?.trim() || repo.default_branch;
699
700 // Validate the selected branch exists (only if repo has commits)
701 if (branches.length > 0 && !branches.includes(newBranch)) {
702 return html(
703 <RepoSettings
704 user={user!}
705 repo={repo}
706 branches={branches}
707 error={`Branch "${newBranch}" does not exist.`}
708 />,
709 );
710 }
711
712 await db
713 .updateTable("repositories")
714 .set({
715 description: description?.trim() || null,
716 is_private: is_private === "1" ? 1 : 0,
717 is_pinned: is_pinned === "1" ? 1 : 0,
718 default_branch: newBranch,
719 issue_template: issue_template?.trim() || null,
720 patch_template: patch_template?.trim() || null,
721 })
722 .where("id", "=", repo.id)
723 .execute();
724
725 // Keep git HEAD in sync if the branch actually exists
726 if (branches.includes(newBranch)) {
727 await git.setHead(repo.name, newBranch).catch(() => {});
728 }
729
730 const updated = await db
731 .selectFrom("repositories")
732 .selectAll()
733 .where("id", "=", repo.id)
734 .executeTakeFirstOrThrow();
735 return html(
736 <RepoSettings
737 user={user!}
738 repo={updated}
739 branches={branches}
740 success="Settings saved."
741 />,
742 );
743 },
744 {
745 body: t.Object({
746 description: t.Optional(t.String()),
747 is_private: t.Optional(t.String()),
748 is_pinned: t.Optional(t.String()),
749 default_branch: t.Optional(t.String()),
750 issue_template: t.Optional(t.String()),
751 patch_template: t.Optional(t.String()),
752 }),
753 },
754 )
755
756 .post("/:repo/settings/delete", async ({ params, cookie }) => {
757 const user = await resolveSession(cookie.session.value);
758 const deny = requireAdmin(user);
759 if (deny) return deny;
760 const repo = await getRepo(params.repo, true);
761 if (!repo) return new Response("Not found", { status: 404 });
762
763 // Remove the on-disk repo first. If this fails (e.g. permission error),
764 // we abort before touching the DB so the repo remains accessible.
765 rmSync(repoPath(repo.name), { recursive: true, force: true });
766 await db.deleteFrom("repositories").where("id", "=", repo.id).execute();
767
768 return new Response(null, { status: 302, headers: { Location: "/" } });
769 });
770