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