lmjtfy.git / apps / lmjtfy / src / browse.rs
browse.rsannotatedbrowse.rssource247 lines · 10.1 KB · raw
1//! `/<repo>.git/<path>` in a browser: the code, browsed. A directory lists
2//! its entries and shows its README; a file is shown with numbered lines, or
3//! rendered if it is markdown; `?raw` serves it as it is.
4//!
5//! Everything comes from GitHub's contents API with the clone proxy's
6//! read-only token, at most once a minute per isolate for a given path.
7//! jevcrates is browsed on its own and where each project pins it: a path
8//! under a project's jevcrates submodule is read from jevcrates at the
9//! pinned commit.
10
11use std::cell::RefCell;
12
13use axum::body::Body;
14use axum::extract::{Path, Query, State};
15use axum::http::{HeaderMap, Response, StatusCode, header};
16use serde::Deserialize;
17use tree::{Contents, Kind};
18
19use super::App;
20use crate::clone::{self, Repo};
21use crate::view::code::{self, Doc, Docs, Shown, View};
22
23/// How long an isolate keeps what GitHub said about a path.
24const KEPT_MS: f64 = 60_000.0;
25/// How many paths an isolate keeps at once; the oldest goes first.
26const KEPT: usize = 64;
27
28thread_local! {
29    /// What GitHub said, by `(repository, ref, path)`, and when.
30    static READ: RefCell<Vec<((Repo, String, String), f64, Read)>> = const { RefCell::new(Vec::new()) };
31    /// Each repository's whole tree at main, submodules mounted, and when.
32    static TREES: RefCell<Vec<(Repo, f64, tree::Tree)>> = const { RefCell::new(Vec::new()) };
33}
34
35/// The whole tree of `repo` at main for the explorer, each submodule's tree
36/// mounted where it is pinned: two calls to GitHub at most, kept a minute.
37/// Empty if GitHub cannot be read; the pages still work without it.
38pub async fn explorer(env: &worker::Env, repo: Repo) -> tree::Tree {
39    let now = js_sys::Date::now();
40    let kept = TREES.with(|trees| trees.borrow().iter().find(|(seen, at, _)| *seen == repo && now - at < KEPT_MS).map(|(_, _, tree)| tree.clone()));
41    if let Some(tree) = kept {
42        return tree;
43    }
44    let Some(mut whole) = git_tree(env, repo, repo.branch()).await else { return tree::Tree::default() };
45    for (mount, inner) in repo.submodules() {
46        let pin = whole.pins.iter().find(|(path, _)| path == mount).map(|(_, sha)| sha.clone());
47        if let Some(inner_tree) = match pin { Some(sha) => git_tree(env, *inner, &sha).await, None => None } {
48            whole.mount(mount, inner_tree);
49        }
50    }
51    TREES.with(|trees| {
52        let mut trees = trees.borrow_mut();
53        trees.retain(|(seen, _, _)| *seen != repo);
54        trees.push((repo, now, whole.clone()));
55    });
56    whole
57}
58
59async fn git_tree(env: &worker::Env, repo: Repo, reference: &str) -> Option<tree::Tree> {
60    let url = format!("https://api.github.com/repos/{}/git/trees/{}?recursive=1", repo.github(), tree::encoded(&[reference]));
61    match clone::github(env, &url).await? {
62        (200, body, _) => tree::parse_tree(&body),
63        _ => None,
64    }
65}
66
67/// What reading a path came to.
68#[derive(Clone)]
69pub enum Read {
70    Found(Contents),
71    /// GitHub said there is nothing there.
72    Missing,
73    /// GitHub could not be asked or did not answer: no token, a limit, an
74    /// outage.
75    Unreachable,
76}
77
78/// `?raw` serves a file as it is; `?view=agents` shows a directory's
79/// CLAUDE.md in place of its README.
80#[derive(Deserialize)]
81pub struct Asked {
82    raw: Option<String>,
83    view: Option<String>,
84}
85
86/// `GET /<repo>/<path>`.
87#[worker::send]
88pub async fn path(
89    State(app): State<App>,
90    Path((segment, path)): Path<(String, String)>,
91    Query(asked): Query<Asked>,
92    headers: HeaderMap,
93) -> Response<Body> {
94    let origin = super::origin(&headers);
95    let Some(repo) = Repo::named(&segment) else { return clone::refused(StatusCode::NOT_FOUND, "No such page.") };
96    let Some(segments) = tree::segments(&path) else { return clone::refused(StatusCode::NOT_FOUND, "No such page.") };
97    let served = segments.join("/");
98    let read = resolve(&app.env, repo, &segments).await;
99    if asked.raw.is_some()
100        && let Read::Found(Contents::File { path, body, .. }) = &read
101    {
102        return raw_file(path, body);
103    }
104    let view = View::asked(asked.view.as_deref());
105    let (status, shown) = match &read {
106        Read::Found(Contents::Dir(entries)) => {
107            let entries = mounted(repo, &served, entries);
108            let docs = docs(&app.env, repo, &segments, &entries, view).await;
109            (StatusCode::OK, Shown::Dir { docs })
110        }
111        Read::Found(Contents::File { size, body, .. }) => (StatusCode::OK, Shown::File { size: *size, body, view }),
112        // A submodule's own path resolves to its root, so this is not seen.
113        Read::Found(Contents::Submodule { .. }) | Read::Missing => (StatusCode::NOT_FOUND, Shown::Missing),
114        Read::Unreachable => (StatusCode::BAD_GATEWAY, Shown::Unreachable),
115    };
116    let whole = explorer(&app.env, repo).await;
117    let latest = clone::latest(&app.env, repo).await;
118    let frame = frame(&origin, repo, &served, &whole, latest.as_ref());
119    let page = code::page(&frame, shown);
120    Response::builder()
121        .status(status)
122        .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
123        .header(header::CACHE_CONTROL, "no-cache")
124        .body(Body::from(page.into_string()))
125        .expect("static headers are valid")
126}
127
128/// Everything around a code page's main pane, for `repo`.
129pub fn frame<'a>(origin: &'a str, repo: Repo, path: &'a str, tree: &'a tree::Tree, latest: Option<&'a card::Commit>) -> code::Frame<'a> {
130    code::Frame {
131        origin,
132        project: repo.project(),
133        path,
134        tree,
135        latest,
136        clone: repo.command(origin),
137        projects: Repo::ALL.iter().map(|repo| repo.project()).collect(),
138    }
139}
140
141/// The root of a repository's documents, for the front page.
142pub async fn root(env: &worker::Env, repo: Repo, view: View) -> Docs {
143    match resolve(env, repo, &[]).await {
144        Read::Found(Contents::Dir(entries)) => docs(env, repo, &[], &entries, view).await,
145        _ => Docs { people: None, agents: None, view },
146    }
147}
148
149/// What is at a served path: in the repository itself, or under one of its
150/// submodules, in the submodule at the commit it is pinned at.
151async fn resolve(env: &worker::Env, repo: Repo, segments: &[&str]) -> Read {
152    for (mount, inner) in repo.submodules() {
153        let mount_segments: Vec<&str> = mount.split('/').collect();
154        if segments.starts_with(&mount_segments) {
155            let pin = match read(env, repo, repo.branch(), &mount_segments).await {
156                Read::Found(Contents::Submodule { sha, .. }) => sha,
157                Read::Found(_) | Read::Missing => return Read::Missing,
158                Read::Unreachable => return Read::Unreachable,
159            };
160            return read(env, *inner, &pin, &segments[mount_segments.len()..]).await;
161        }
162    }
163    read(env, repo, repo.branch(), segments).await
164}
165
166/// A directory's entries with their paths as served: a submodule's entries
167/// are under its mount.
168fn mounted(repo: Repo, served: &str, entries: &[tree::Entry]) -> Vec<tree::Entry> {
169    let mount = repo.submodules().iter().map(|(mount, _)| *mount).find(|mount| served == *mount || served.starts_with(&format!("{mount}/")));
170    entries
171        .iter()
172        .map(|entry| tree::Entry {
173            path: match mount {
174                Some(mount) => format!("{mount}/{}", entry.path),
175                None => entry.path.clone(),
176            },
177            ..entry.clone()
178        })
179        .collect()
180}
181
182/// The directory's README and CLAUDE.md, rendered.
183async fn docs(env: &worker::Env, repo: Repo, segments: &[&str], entries: &[tree::Entry], view: View) -> Docs {
184    Docs {
185        people: doc(env, repo, segments, entries, "README.md").await,
186        agents: doc(env, repo, segments, entries, "CLAUDE.md").await,
187        view,
188    }
189}
190
191async fn doc(env: &worker::Env, repo: Repo, segments: &[&str], entries: &[tree::Entry], name: &str) -> Option<Doc> {
192    let found = entries.iter().find(|entry| entry.kind == Kind::File && entry.name.eq_ignore_ascii_case(name))?;
193    let mut path = segments.to_vec();
194    path.push(&found.name);
195    match resolve(env, repo, &path).await {
196        Read::Found(Contents::File { body: tree::Body::Text(text), .. }) => Some(Doc::new(&path.join("/"), &text, &repo.project().base())),
197        _ => None,
198    }
199}
200
201/// One path of one repository at one ref, from this isolate's memory or
202/// from GitHub.
203async fn read(env: &worker::Env, repo: Repo, reference: &str, segments: &[&str]) -> Read {
204    let key = (repo, reference.to_owned(), segments.join("/"));
205    let now = js_sys::Date::now();
206    let kept = READ.with(|read| {
207        read.borrow().iter().find(|(seen, at, _)| *seen == key && now - at < KEPT_MS).map(|(_, _, read)| read.clone())
208    });
209    if let Some(read) = kept {
210        return read;
211    }
212    let url = format!("https://api.github.com/repos/{}/contents/{}?ref={}", repo.github(), tree::encoded(segments), tree::encoded(&[reference]));
213    let read = match clone::github(env, &url).await {
214        Some((200, body, _)) => tree::parse(&body).map_or(Read::Unreachable, Read::Found),
215        Some((404, _, _)) => Read::Missing,
216        _ => Read::Unreachable,
217    };
218    // Only answers are kept: an outage is asked about again next time.
219    if !matches!(read, Read::Unreachable) {
220        READ.with(|kept| {
221            let mut kept = kept.borrow_mut();
222            kept.retain(|(seen, at, _)| *seen != key && now - at < KEPT_MS);
223            if kept.len() >= KEPT {
224                kept.remove(0);
225            }
226            kept.push((key, now, read.clone()));
227        });
228    }
229    read
230}
231
232/// A file as it is. A sandbox keeps anything in it from running as this
233/// site.
234fn raw_file(path: &str, body: &tree::Body) -> Response<Body> {
235    let bytes = match body {
236        tree::Body::Text(text) => text.clone().into_bytes(),
237        tree::Body::Binary(bytes) => bytes.clone(),
238        tree::Body::TooLarge => return clone::refused(StatusCode::PAYLOAD_TOO_LARGE, "Too large to show here. Clone it."),
239    };
240    Response::builder()
241        .header(header::CONTENT_TYPE, tree::media_type(path, body))
242        .header(header::CACHE_CONTROL, "public, max-age=60")
243        .header("content-security-policy", "sandbox")
244        .header("x-content-type-options", "nosniff")
245        .body(Body::from(bytes))
246        .expect("static headers are valid")
247}