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 ? "text/plain; charset=utf-8"
42 : "application/octet-stream";
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 = 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 = 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 }) => {
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 = 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 return new Response(content, {
435 headers: {
436 "Content-Type": contentType,
437 "Content-Disposition": `inline; filename="${filename}"`,
438 "Content-Length": String(content.length),
439 },
440 });
441 })
442
443 .get(
444 "/:repo/commits/:ref",
445 async ({ params, cookie, query }) => {
446 const user = await resolveSession(cookie.session.value);
447 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
448 if (!repo) return new Response("Not found", { status: 404 });
449
450 // Cursor-based pagination — O(1) regardless of history depth.
451 // `after` = SHA of the last commit on the previous page (resume cursor).
452 // `prev` = the `after` value used on the page that linked here, so we can
453 // reconstruct a "← Newer" link without a full history traversal.
454 const after = query.after?.trim() || null;
455 const prev = query.prev?.trim() || null;
456
457 const [rawCommits, branches] = await Promise.all([
458 // When `after` is set: start at that SHA and skip it (--skip=1 is O(1)),
459 // then fetch LIMIT+1 to detect whether another page exists.
460 after
461 ? git.log(repo.name, after, COMMITS_PER_PAGE + 1, 1)
462 : git.log(repo.name, params.ref, COMMITS_PER_PAGE + 1, 0),
463 git.branches(repo.name),
464 ]);
465
466 const hasNext = rawCommits.length > COMMITS_PER_PAGE;
467 const commits = rawCommits.slice(0, COMMITS_PER_PAGE);
468
469 // Build cursor URLs.
470 // "Older" advances past the last commit on this page.
471 // "Newer" goes back one page using the `prev` cursor saved in the URL,
472 // or to the first page if we're on page 2.
473 const base = `/${repo.name}/commits/${params.ref}`;
474 const olderUrl = hasNext
475 ? `${base}?after=${commits[commits.length - 1]?.hash}&prev=${after ?? ""}`
476 : null;
477 const newerUrl = after
478 ? prev
479 ? `${base}?after=${prev}`
480 : base
481 : null;
482
483 return html(
484 <CommitLog
485 user={user}
486 repo={repo}
487 ref={params.ref}
488 commits={commits}
489 branches={branches}
490 olderUrl={olderUrl}
491 newerUrl={newerUrl}
492 />,
493 );
494 },
495 {
496 query: t.Object({
497 after: t.Optional(t.String()),
498 prev: t.Optional(t.String()),
499 }),
500 },
501 )
502
503 .get("/:repo/commit/:sha", async ({ params, cookie }) => {
504 const user = await resolveSession(cookie.session.value);
505 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
506 if (!repo) return new Response("Not found", { status: 404 });
507
508 const [meta, rawDiff] = await Promise.all([
509 git.commitMeta(repo.name, params.sha),
510 git.diff(repo.name, params.sha),
511 ]);
512 if (!meta) return new Response("Commit not found", { status: 404 });
513 const files = await prepareDiff(
514 rawDiff,
515 `commit:${repo.name}:${params.sha}`,
516 );
517 return html(
518 <CommitDetail
519 user={user}
520 repo={repo}
521 sha={params.sha}
522 meta={meta}
523 files={files}
524 />,
525 );
526 })
527
528 .get("/:repo/settings", async ({ params, cookie }) => {
529 const user = await resolveSession(cookie.session.value);
530 const deny = requireAdmin(user);
531 if (deny) return deny;
532 const repo = await getRepo(params.repo, true);
533 if (!repo) return new Response("Not found", { status: 404 });
534 const branches = await git.branches(repo.name);
535 return html(
536 <RepoSettings user={user!} repo={repo} branches={branches} />,
537 );
538 })
539
540 .post(
541 "/:repo/settings",
542 async ({ params, body, cookie }) => {
543 const user = await resolveSession(cookie.session.value);
544 const deny = requireAdmin(user);
545 if (deny) return deny;
546 const repo = await getRepo(params.repo, true);
547 if (!repo) return new Response("Not found", { status: 404 });
548
549 const { description, is_private, default_branch } = body;
550
551 const branches = await git.branches(repo.name);
552 const newBranch = default_branch?.trim() || repo.default_branch;
553
554 // Validate the selected branch exists (only if repo has commits)
555 if (branches.length > 0 && !branches.includes(newBranch)) {
556 return html(
557 <RepoSettings
558 user={user!}
559 repo={repo}
560 branches={branches}
561 error={`Branch "${newBranch}" does not exist.`}
562 />,
563 );
564 }
565
566 await db
567 .updateTable("repositories")
568 .set({
569 description: description?.trim() || null,
570 is_private: is_private === "1" ? 1 : 0,
571 default_branch: newBranch,
572 })
573 .where("id", "=", repo.id)
574 .execute();
575
576 // Keep git HEAD in sync if the branch actually exists
577 if (branches.includes(newBranch)) {
578 await git.setHead(repo.name, newBranch).catch(() => {});
579 }
580
581 const updated = await db
582 .selectFrom("repositories")
583 .selectAll()
584 .where("id", "=", repo.id)
585 .executeTakeFirstOrThrow();
586 return html(
587 <RepoSettings
588 user={user!}
589 repo={updated}
590 branches={branches}
591 success="Settings saved."
592 />,
593 );
594 },
595 {
596 body: t.Object({
597 description: t.Optional(t.String()),
598 is_private: t.Optional(t.String()),
599 default_branch: t.Optional(t.String()),
600 }),
601 },
602 )
603
604 .post("/:repo/settings/delete", async ({ params, cookie }) => {
605 const user = await resolveSession(cookie.session.value);
606 const deny = requireAdmin(user);
607 if (deny) return deny;
608 const repo = await getRepo(params.repo, true);
609 if (!repo) return new Response("Not found", { status: 404 });
610
611 // Remove the on-disk repo first. If this fails (e.g. permission error),
612 // we abort before touching the DB so the repo remains accessible.
613 rmSync(repoPath(repo.name), { recursive: true, force: true });
614 await db.deleteFrom("repositories").where("id", "=", repo.id).execute();
615
616 return new Response(null, { status: 302, headers: { Location: "/" } });
617 });
618