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