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}