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