summaryrefslogtreecommitdiff
path: root/cgithub/src/app.ts
diff options
context:
space:
mode:
Diffstat (limited to 'cgithub/src/app.ts')
-rw-r--r--cgithub/src/app.ts284
1 files changed, 284 insertions, 0 deletions
diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts
new file mode 100644
index 0000000..0b9a7e6
--- /dev/null
+++ b/cgithub/src/app.ts
@@ -0,0 +1,284 @@
+import type { Eta } from "eta";
+import { type Context, Hono } from "hono";
+import type { StatusCode } from "hono/utils/http-status";
+import { buildCommit } from "./build.generated.ts";
+import {
+ getGitHubBlob,
+ getGitHubCommits,
+ getGitHubIssues,
+ getGitHubSidebar,
+ getGitHubLatestCommit,
+ getGitHubOwner,
+ getGitHubPulls,
+ getGitHubRaw,
+ getGitHubRefs,
+ getGitHubRelease,
+ getGitHubReleases,
+ getGitHubRepo,
+ getGitHubTree,
+ getGitHubWiki,
+ getGitHubWikiPages,
+ ExternalRedirectError,
+ GitHubHTTPError,
+ InternalRedirectError,
+ RedirectError,
+} from "./scraper.ts";
+
+declare module "hono" {
+ interface ContextRenderer {
+ (template: string, data?: object): Response;
+ }
+}
+
+function githubUrlFor(c: Context) {
+ const { pathname, search } = new URL(c.req.url);
+ return `https://github.com${pathname}${search}`;
+}
+
+export function createApp(eta: Eta) {
+ const app = new Hono();
+
+ app.use(async (c, next) => {
+ // Include render data for views/layouts/base.eta.
+ c.setRenderer((template, data) =>
+ c.html(eta.render(template, { ...data, githubUrl: githubUrlFor(c), buildCommit })),
+ );
+ await next();
+ });
+
+ // Redirect off-domain.
+ function redirectTo(c: Context, location: string) {
+ const referer = c.req.header("Referer");
+ // If we are the referer, redirect directly so we can hotlink resources
+ // (e.g., <img> in README).
+ if (referer && URL.parse(referer)?.origin === new URL(c.req.url).origin) {
+ c.header("Referrer-Policy", "no-referrer");
+ return c.redirect(location);
+ }
+
+ // Otherwise use a meta refresh to make ourself the initiator. This will
+ // avoid redirect loops if we are part of excludedInitiatorDomains in a
+ // declarativeNetRequest.
+ return c.render("redirect.eta", { location });
+ }
+
+ function redirectToGitHub(c: Context) {
+ return redirectTo(c, githubUrlFor(c));
+ }
+
+ app.onError((e, c) => {
+ // Fragments fetched asynchronously by client-side JS (see public/static/refs.js)
+ // fail as an empty body rather than the full error.eta page.
+ if (c.req.path.startsWith("/api/")) {
+ c.status(
+ e instanceof GitHubHTTPError
+ ? (e.status as StatusCode)
+ : e instanceof RedirectError
+ ? 404
+ : 500,
+ );
+ return c.body(null);
+ }
+
+ if (e instanceof InternalRedirectError) return c.redirect(e.location);
+ if (e instanceof ExternalRedirectError) return redirectTo(c, e.location);
+ if (e instanceof GitHubHTTPError) {
+ c.status(e.status as StatusCode);
+ const message = `GitHub responded with HTTP ${e.status} ${e.message}`;
+ return c.render("error.eta", { title: e.message, message });
+ }
+ c.status(500);
+ return c.render("error.eta", { message: "" + e });
+ });
+
+ app.get("/", async (c) => {
+ return c.render("home.eta");
+ });
+
+ // Exclusion that immediately redirects to GitHub.
+ app.get("/notifications", async (c) => {
+ return redirectToGitHub(c);
+ });
+
+ // GitHub's global search, which we only serve when it is scoped to a single
+ // repository: strip the qualifier and let that repository's search route
+ // decide what to do with the rest.
+ app.get("/search", async (c) => {
+ const q = c.req.query("q") ?? "";
+ const type = c.req.query("type");
+
+ const repoQualifier = /(?:^|\s)repo:([^/\s]+)\/([^/\s]+)(?=\s|$)/.exec(q);
+ if (type && repoQualifier) {
+ const [qualifier, owner, repo] = repoQualifier;
+ const rest =
+ q.slice(0, repoQualifier.index) + q.slice(repoQualifier.index + qualifier.length);
+ const params = new URLSearchParams({ q: rest.trim(), type });
+ return c.redirect(`/${owner}/${repo}/search?${params}`);
+ }
+
+ return redirectToGitHub(c);
+ });
+
+ app.get("/:owner", async (c) => {
+ const { owner } = c.req.param();
+ return c.render("owner.eta", await getGitHubOwner(owner));
+ });
+
+ app.get("/:owner/:repo", async (c) => {
+ const { owner, repo } = c.req.param();
+ return c.render("repo.eta", await getGitHubRepo(owner, repo));
+ });
+
+ app.get("/:owner/:repo/tree/:branch/:path{.*}?", async (c) => {
+ const { owner, repo, branch, path = "" } = c.req.param();
+ return c.render("tree.eta", await getGitHubTree(owner, repo, branch, path));
+ });
+
+ app.get("/:owner/:repo/blob/:branch/:path{.*}", async (c) => {
+ const { owner, repo, branch, path } = c.req.param();
+ return c.render("blob.eta", await getGitHubBlob(owner, repo, branch, path));
+ });
+
+ app.get("/:owner/:repo/raw/:branch/:path{.*}", async (c) => {
+ const { owner, repo, branch, path } = c.req.param();
+ return redirectTo(c, `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`);
+ });
+
+ // This is not a native GitHub route, but instead it lets us render blob
+ // content whose raw headers prevent hotlinking.
+ app.get("/:owner/:repo/embed/:type/:branch/:path{.*}", async (c) => {
+ const { owner, repo, type, branch, path } = c.req.param();
+
+ if (type !== "pdf") {
+ return c.notFound();
+ }
+
+ let response: Response;
+ try {
+ response = await getGitHubRaw(owner, repo, branch, path);
+ } catch (e) {
+ c.status(e instanceof GitHubHTTPError ? (e.status as StatusCode) : 500);
+ return c.body(null);
+ }
+
+ let contentType = response.headers.get("Content-Type");
+ if (contentType === null || contentType === "application/octet-stream") {
+ contentType = "application/pdf";
+ }
+
+ return new Response(response.body, { headers: { "Content-Type": contentType } });
+ });
+
+ // GitHub's own wiki URLs, in the order it resolves them: names beginning with
+ // an underscore are reserved for its actions (_pages, _new, _edit, ...), so a
+ // page can never be mistaken for one.
+ app.get("/:owner/:repo/wiki", async (c) => {
+ const { owner, repo } = c.req.param();
+ return c.render("wiki.eta", await getGitHubWiki(owner, repo));
+ });
+
+ app.get("/:owner/:repo/wiki/_pages", async (c) => {
+ const { owner, repo } = c.req.param();
+ return c.render("wikiPages.eta", await getGitHubWikiPages(owner, repo));
+ });
+
+ // An older revision of a page renders exactly like its current one.
+ app.get("/:owner/:repo/wiki/:page/:oid", async (c) => {
+ const { owner, repo, page, oid } = c.req.param();
+ if (page.startsWith("_") || !/^[0-9a-f]{40}$/.test(oid)) {
+ return redirectToGitHub(c);
+ }
+ return c.render("wiki.eta", await getGitHubWiki(owner, repo, page, oid));
+ });
+
+ app.get("/:owner/:repo/wiki/:page", async (c) => {
+ const { owner, repo, page } = c.req.param();
+ if (page.startsWith("_")) {
+ return redirectToGitHub(c);
+ }
+ return c.render("wiki.eta", await getGitHubWiki(owner, repo, page));
+ });
+
+ app.get("/:owner/:repo/issues", async (c) => {
+ const { owner, repo } = c.req.param();
+ const q = c.req.query("q");
+
+ // GitHub supports combined issue/PR search, apparently for legacy. We just
+ // redirect to issue-only search.
+ if (q && !/\bis:issue\b/.test(q)) {
+ return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(`is:issue ${q}`)}`);
+ }
+
+ return c.render("issues.eta", await getGitHubIssues(owner, repo, { q }));
+ });
+
+ app.get("/:owner/:repo/pulls", async (c) => {
+ const { owner, repo } = c.req.param();
+ const q = c.req.query("q");
+ return c.render("pulls.eta", await getGitHubPulls(owner, repo, { q }));
+ });
+
+ app.get("/:owner/:repo/search", async (c) => {
+ const { owner, repo } = c.req.param();
+ const q = c.req.query("q") ?? "";
+ const type = c.req.query("type") ?? "issues";
+
+ if (type === "issues") {
+ return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(q)}`);
+ } else if (type === "pullrequests") {
+ return c.redirect(`/${owner}/${repo}/pulls?q=${encodeURIComponent(q)}`);
+ }
+
+ // Redirect unhandled search types (like code, which requires sign in anyway).
+ return redirectToGitHub(c);
+ });
+
+ app.get("/:owner/:repo/commits/:branch/:path{.*}?", async (c) => {
+ const { owner, repo, branch, path = "" } = c.req.param();
+ return c.render("commits.eta", await getGitHubCommits(owner, repo, branch, path));
+ });
+
+ // Internal endpoints fetched asynchronously by client-side JS, not meant to
+ // be visited directly. Keep the site's initial page loads plain server-
+ // rendered HTML while still surfacing data (branches/tags, latest commit)
+ // that would otherwise require an extra scrape on every page view.
+ // The view and path are where the picker was opened, so each ref can link to
+ // the same page on another ref.
+ app.get("/api/:owner/:repo/refs/:type/:view/:path{.*}?", async (c) => {
+ const { owner, repo, type, view, path = "" } = c.req.param();
+ if (type !== "branches" && type !== "tags") {
+ return c.notFound();
+ }
+ if (view !== "tree" && view !== "blob" && view !== "commits") {
+ return c.notFound();
+ }
+ const refs = await getGitHubRefs(owner, repo, type);
+ return c.render("_refs.eta", { ...refs, view, path });
+ });
+
+ app.get("/api/:owner/:repo/latest-commit/:branch/:path{.*}?", async (c) => {
+ const { owner, repo, branch, path = "" } = c.req.param();
+ return c.render("_latestCommit.eta", await getGitHubLatestCommit(owner, repo, branch, path));
+ });
+
+ app.get("/api/:owner/:repo/sidebar", async (c) => {
+ const { owner, repo } = c.req.param();
+ return c.render("_sidebar.eta", await getGitHubSidebar(owner, repo));
+ });
+
+ app.get("/:owner/:repo/releases/tag/:tag", async (c) => {
+ const { owner, repo, tag } = c.req.param();
+ return c.render("release.eta", await getGitHubRelease(owner, repo, tag));
+ });
+
+ app.get("/:owner/:repo/releases", async (c) => {
+ const { owner, repo } = c.req.param();
+ return c.render("releases.eta", await getGitHubReleases(owner, repo));
+ });
+
+ app.all("*", async (c) => {
+ return redirectToGitHub(c);
+ });
+
+ return app;
+}