import type { Request, Response } from "express"; import { config } from "../config.ts"; import * as git from "../git.ts"; import { getRepo, listRepos, type Repo } from "../repos.ts"; import { blobPage, emptyRepoPage, errorPage, highlightSource, layout, renderMarkdown, repoListPage, treePage, } from "../views.ts"; const README_NAMES = ["readme.md", "readme.markdown", "readme.txt", "readme"]; export function sendError(req: Request, res: Response, status: number, message: string): void { res .status(status) .type("html") .send(layout({ title: `${status}`, isOwner: req.isOwner, body: errorPage(status, message) })); } export async function repoIndex(req: Request, res: Response): Promise { const repos = await listRepos(); const visible = req.isOwner ? repos : repos.filter((repo) => repo.public); res.type("html").send( layout({ title: config.siteName, isOwner: req.isOwner, body: repoListPage(visible, req.isOwner), }), ); } /** Privé-repos bestaan simpelweg niet voor bezoekers die niet ingelogd zijn. */ async function resolveVisibleRepo(req: Request, name: string): Promise { const repo = await getRepo(name); if (!repo) return null; if (!repo.public && !req.isOwner) return null; return repo; } async function findReadme( repo: Repo, branch: string, filePath: string, entries: git.TreeEntry[], ): Promise { const match = entries.find( (entry) => entry.type === "blob" && README_NAMES.includes(entry.name.toLowerCase()), ); if (!match) return null; if (match.size !== null && match.size > config.maxRenderBytes) return null; const fullPath = filePath ? `${filePath}/${match.name}` : match.name; try { const buffer = await git.readBlob(repo.publishedDir, branch, fullPath, config.maxRenderBytes); if (git.looksBinary(buffer)) return null; const text = buffer.toString("utf8"); const lower = match.name.toLowerCase(); if (lower.endsWith(".md") || lower.endsWith(".markdown")) return renderMarkdown(text); return `
${text.replaceAll("&", "&").replaceAll("<", "<")}
`; } catch { return null; } } /** * Eén handler voor alles onder `/r`, in plaats van losse routes met wildcards: * een branchnaam mag schuine strepen bevatten, dus we knippen het pad zelf uit * elkaar. Alles wat hier gelezen wordt komt uit de gepubliceerde repository, dus * ook als jij zelf ingelogd bent zie je nooit meer dan een bezoeker. */ export async function repoBrowser(req: Request, res: Response): Promise { const segments = req.path .split("/") .filter(Boolean) .map((part) => { try { return decodeURIComponent(part); } catch { return part; } }); const name = segments[0]; if (!name) { res.redirect("/"); return; } const repo = await resolveVisibleRepo(req, name); if (!repo) { sendError(req, res, 404, "Deze repository bestaat niet."); return; } const branches = await git.listPublishedBranches(repo.publishedDir); const view = segments[1]; if (branches.length === 0) { res .type("html") .send(layout({ title: repo.name, isOwner: req.isOwner, body: emptyRepoPage(repo) })); return; } if (!view) { const head = (await git.publishedHeadBranch(repo.publishedDir)) ?? branches[0]!; res.redirect(`/r/${encodeURIComponent(repo.name)}/tree/${head}`); return; } if (view !== "tree" && view !== "blob" && view !== "raw") { sendError(req, res, 404, "Onbekende weergave."); return; } const resolved = await git.splitBranchAndPath(repo.publishedDir, segments.slice(2).join("/")); if (!resolved) { sendError(req, res, 404, "Onbekende branch."); return; } const { branch, filePath } = resolved; const type = await git.objectType(repo.publishedDir, branch, filePath); if (type === null) { sendError(req, res, 404, "Dit bestand of deze map bestaat niet."); return; } if (view === "tree") { if (type !== "tree") { res.redirect( `/r/${encodeURIComponent(repo.name)}/blob/${branch}${filePath ? `/${filePath}` : ""}`, ); return; } const entries = await git.listTree(repo.publishedDir, branch, filePath); const [readmeHtml, snapshot] = await Promise.all([ findReadme(repo, branch, filePath, entries), git.snapshotDate(repo.publishedDir, branch), ]); res.type("html").send( layout({ title: `${repo.name}${filePath ? `/${filePath}` : ""}`, isOwner: req.isOwner, body: treePage({ repo, branch, filePath, entries, branches, readmeHtml, snapshot, isOwner: req.isOwner, }), }), ); return; } if (type !== "blob") { res.redirect( `/r/${encodeURIComponent(repo.name)}/tree/${branch}${filePath ? `/${filePath}` : ""}`, ); return; } const size = await git.blobSize(repo.publishedDir, branch, filePath); const fileName = filePath.slice(filePath.lastIndexOf("/") + 1); if (view === "raw") { if (size > 64 * 1024 * 1024) { sendError(req, res, 413, "Bestand te groot om te serveren."); return; } const buffer = await git.readBlob(repo.publishedDir, branch, filePath, size + 1); // Nooit het echte content-type raden: dan zou een HTML- of JS-bestand uit een // publieke repo als script op dit domein kunnen draaien. res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("Content-Security-Policy", "sandbox"); res.setHeader("Content-Disposition", `inline; filename="${fileName.replaceAll('"', "")}"`); res.type(git.looksBinary(buffer) ? "application/octet-stream" : "text/plain; charset=utf-8"); res.send(buffer); return; } let content: Parameters[0]["content"]; if (size > config.maxRenderBytes) { content = { kind: "toolarge" }; } else { const buffer = await git.readBlob(repo.publishedDir, branch, filePath, config.maxRenderBytes); if (git.looksBinary(buffer)) { content = { kind: "binary" }; } else { const text = buffer.toString("utf8"); const lower = fileName.toLowerCase(); content = lower.endsWith(".md") || lower.endsWith(".markdown") ? { kind: "markdown", html: renderMarkdown(text) } : { kind: "text", html: highlightSource(text, fileName) }; } } res.type("html").send( layout({ title: `${repo.name}/${filePath}`, isOwner: req.isOwner, body: blobPage({ repo, branch, filePath, branches, size, content, isOwner: req.isOwner }), }), ); }