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