//! What the web pages show, as JSON, under `/_data` and the page's own //! path. The pages themselves are a SvelteKit app in `web/`: this server //! hands every request it does not answer itself to it, and the app reads //! back here for what to show. //! //! Writing is for git, the CLI and agents over MCP; these only show. Each //! answer's type is written out for the app, as TypeScript, by `cargo test`. use crate::card::{self, Card}; use crate::diff::{self, Kind, Status}; use crate::objects::{self, Objects}; use crate::{App, AppError, highlight, repo_name}; use axum::Router; use axum::extract::{Path, Query, Request, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use knotless::forge::{self, Item, Thread}; use knotless::git::Git; use knotless::read; use knotless::records::{RepoRecord, RepoRef}; use knotless::remote::Remote; use knotless::session; use knotless::url::RepoUrl; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fmt::Write; use std::sync::{Arc, LazyLock, Mutex}; use std::time::{Duration, Instant}; use ts_rs::TS; mod feed; mod pages; pub use feed::get_feed; pub use pages::pages; pub fn routes() -> Router> { Router::new() .route("/_data/search", get(search)) .route("/_data/{authority}", get(profile)) .route("/_data/{authority}/{repo}", get(code)) .route("/_data/{authority}/{repo}/tree/{*rest}", get(tree)) .route("/_data/{authority}/{repo}/blob/{*rest}", get(blob)) .route("/_data/{authority}/{repo}/commits/{*rest}", get(commits)) .route("/_data/{authority}/{repo}/commit/{rev}", get(commit)) .route("/_data/{authority}/{repo}/pulls", get(pulls)) .route("/_data/{authority}/{repo}/issues", get(issues)) .route("/_data/{authority}/{repo}/{kind}/{did}/{rkey}", get(item)) .route("/{authority}/{repo}/raw/{*rest}", get(raw)) .route("/card.png", get(home_card)) .route("/{authority}/{repo}/card.png", get(repo_card)) .route( "/{authority}/{repo}/{kind}/{did}/{rkey}/card.png", get(item_card_png), ) .layer(middleware::from_fn(renamed)) .layer(tower_http::compression::CompressionLayer::new()) } /// Where a repository is now, on the answer to a request that used a name /// it had before: its name, and its owner if it moved to another account. #[derive(Clone)] struct Renamed(String); /// Points a request made with a repository's old name at the same path /// under its new one. The redirect is temporary: the old name can be given /// to another repository. async fn renamed(req: Request, next: Next) -> Response { let path = req.uri().path().to_string(); let query = req .uri() .query() .map(|q| format!("?{q}")) .unwrap_or_default(); let mut res = next.run(req).await; if let Some(Renamed(now)) = res.extensions_mut().remove::() { let (prefix, rest) = match path.strip_prefix("/_data") { Some(rest) => ("/_data", rest), None => ("", path.as_str()), }; // "", the authority, the name, and the rest. let mut parts: Vec<&str> = rest.splitn(4, '/').collect(); match now.split_once('/') { Some((owner, name)) => (parts[1], parts[2]) = (owner, name), None => parts[2] = &now, } let to = format!("{prefix}{}{query}", parts.join("/")); res.headers_mut() .insert(header::LOCATION, HeaderValue::from_str(&to).unwrap()); } res } /// Text inlined above, past this, is a link to the raw file instead. const INLINE: usize = 1024 * 1024; const COMMITS_PER_PAGE: usize = 50; /// A file's diff with more rows than this is folded away. const DIFF_ROWS: usize = 2000; /// A path for a URL: each segment percent-encoded, the slashes kept. fn url_path(path: &str) -> String { let mut out = String::new(); for b in path.bytes() { match b { b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' | b'@' => out.push(b as char), b => write!(out, "%{b:02X}").unwrap(), } } out } /// Caching for a file at `rev`: forever when it names a commit by hash, /// else checked again each time, as a branch can move. fn cache_for(rev: &str) -> &'static str { if read::is_pinned(rev) { "public, max-age=31536000, immutable" } else { "no-cache" } } /// `value` as the answer, checked again each time: even at a commit hash /// it shows the repository as it is now, its authors' accounts, and a shape /// a later release may change. fn answer(value: T) -> Response { let mut res = axum::Json(value).into_response(); res.headers_mut() .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); res } /// A failure, as the pages show it: a status and why. pub struct Shown(StatusCode, String); impl From for Shown { fn from(e: AppError) -> Shown { Shown(e.0, e.1) } } #[derive(Serialize, TS)] #[ts(export)] pub struct Failure { message: String, } impl IntoResponse for Shown { fn into_response(self) -> Response { if self.0 == StatusCode::TEMPORARY_REDIRECT { let mut res = self.0.into_response(); res.extensions_mut().insert(Renamed(self.1)); return res; } let mut res = (self.0, axum::Json(Failure { message: self.1 })).into_response(); res.headers_mut() .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); res } } fn not_found(e: anyhow::Error) -> Shown { Shown(StatusCode::NOT_FOUND, format!("{e:#}")) } type Result = std::result::Result; static HANDLES: LazyLock>> = LazyLock::new(Mutex::default); /// The handle `did` claims, if it resolves back to `did`; else the DID. async fn handle(app: &App, did: &str) -> String { if let Some(known) = HANDLES.lock().unwrap().get(did) { return known.clone(); } let shown = knotless::identity::handle(&app.ctx, did) .await .unwrap_or(did.to_string()); HANDLES .lock() .unwrap() .insert(did.to_string(), shown.clone()); shown } /// How long what an account says of itself is kept before it is looked up /// again: its picture, and the addresses it commits as. const KEPT_FOR: Duration = Duration::from_secs(600); /// Values looked up over the network, by key, each kept for a while. struct Kept { values: Mutex>, ttl: Duration, } impl Kept { fn new(ttl: Duration) -> Kept { Kept { values: Mutex::default(), ttl, } } /// The value kept for `key`, unless it is missing or too old. fn get(&self, key: &str) -> Option { let values = self.values.lock().unwrap(); let (at, value) = values.get(key)?; (at.elapsed() < self.ttl).then(|| value.clone()) } fn put(&self, key: &str, value: V) { let kept = (Instant::now(), value); self.values.lock().unwrap().insert(key.to_string(), kept); } } static AVATARS: LazyLock>> = LazyLock::new(|| Kept::new(KEPT_FOR)); static CLAIMS: LazyLock>> = LazyLock::new(|| Kept::new(KEPT_FOR)); /// Where Bluesky's CDN serves profile pictures, by DID and blob CID. const AVATAR_CDN: &str = "https://cdn.bsky.app/img/avatar/plain"; /// The account's Bluesky profile record, or nothing, if it has none. async fn bsky_profile(remote: &Remote) -> serde_json::Value { let found = remote .get_record::("app.bsky.actor.profile", "self") .await; found.ok().flatten().map(|r| r.value).unwrap_or_default() } /// The picture `about`, `did`'s profile record, names, as Bluesky's CDN /// serves it. fn avatar_url(did: &str, about: &serde_json::Value) -> Option { let cid = about["avatar"]["ref"]["$link"].as_str()?; Some(format!("{AVATAR_CDN}/{did}/{cid}@jpeg")) } /// `did`'s profile picture, if it has one: looked up now and then, and /// again whenever its profile page is shown. async fn avatar(app: &App, did: &str) -> Option { if let Some(known) = AVATARS.get(did) { return known; } let at = RepoUrl { authority: did.to_string(), name: "_".into(), }; let remote = Remote::connect(&app.ctx, &at).await.ok(); let about = futures::future::OptionFuture::from(remote.as_ref().map(bsky_profile)).await; let found = avatar_url(did, &about.unwrap_or_default()); AVATARS.put(did, found.clone()); found } /// The addresses `did` claims to commit as, hashed (`knotless::emails`). async fn claims(app: &App, did: &str) -> Vec { if let Some(known) = CLAIMS.get(did) { return known; } let found = knotless::emails::claimed(&app.ctx, did).await; let found = found.unwrap_or_default(); CLAIMS.put(did, found.clone()); found } /// `text` on one line, cut at `n` characters. fn summary(text: &str, n: usize) -> String { let flat = text.split_whitespace().collect::>().join(" "); match flat.char_indices().nth(n) { Some((at, _)) => format!("{}…", flat[..at].trim_end()), None => flat, } } /// The path of a page's card, under the version of what it shows. fn card_path(page: &str, card: &Card) -> String { format!("{page}/card.png?v={}", card::version(&card.svg())) } /// Something shown by name, and its page. #[derive(Serialize, TS, Clone, Debug, PartialEq)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Link { shown: String, href: String, } /// `handle/name`, and its page. fn named(owner: &str, name: &str) -> Link { Link { shown: format!("{owner}/{name}"), href: format!("/{}/{}", url_path(owner), url_path(name)), } } /// A repository, under the name it has now if the index knows it. async fn repo_link(app: &App, repo: &RepoRef) -> Option { let (did, _, named_then) = forge::parse_uri(&repo.uri).ok()?; let now = app .index .as_ref() .and_then(|i| i.name(did, &repo.key).ok().flatten()); let name = now.as_deref().unwrap_or(named_then); Some(named(&handle(app, did).await, name)) } /// A commit, as the pages show one. #[derive(Serialize, TS, Clone, Debug, PartialEq)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Commit { sha: String, parents: Vec, author: Author, /// Who its message's `Co-authored-by` trailers name. coauthors: Vec, date: String, message: String, } /// Who wrote a commit or helped to: by the name git gives, and by account, /// with its picture, when that is one of the repository's own people whose /// address it is (`knotless::emails`). #[derive(Serialize, TS, Clone, Debug, PartialEq)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Author { name: String, /// Its handle. account: Option, avatar: Option, } /// One whose claims a repository's pages believe: its owner and its /// collaborators everywhere in it, and the author of a pull merged into it /// only in the commits the pull brought in, which the merge vouched for. struct Vouched { did: String, /// The addresses it claims, hashed (`knotless::emails`). hashes: Vec, /// The commits it is believed in, if not all. only: Option>, } /// `name` at `email`, by account if `email` is one of `people`'s in `sha`. async fn author(app: &App, people: &[Vouched], sha: &str, name: String, email: &str) -> Author { let by = people.iter().find(|p| { let here = p.only.as_ref().is_none_or(|only| only.contains(sha)); here && knotless::emails::is(&p.did, email, &p.hashes) }); let (account, avatar) = match by { Some(p) => (Some(handle(app, &p.did).await), avatar(app, &p.did).await), None => (None, None), }; Author { name, account, avatar, } } /// The co-authors a commit message's closing paragraph names, as /// `Co-authored-by: Name ` trailers: names and addresses. fn coauthors(message: &str) -> Vec<(String, String)> { let trailers = message .trim_end() .rsplit_once("\n\n") .map_or("", |(_, t)| t); trailers.lines().filter_map(coauthor).collect() } fn coauthor(line: &str) -> Option<(String, String)> { let (key, value) = line.split_once(':')?; let named = key.trim().eq_ignore_ascii_case("co-authored-by"); let (name, email) = value.split_once('<').filter(|_| named)?; let email = email.trim_end().strip_suffix('>')?; Some((name.trim().to_string(), email.trim().to_string())) } /// What a merge as `merged` brought in, as `git rev-list` takes it: from /// where the base was before it, or, for a merge recorded without that, a /// merge commit's second parent less its first, which names nothing for /// any other kind of merge. fn brought(merged: &str, from: Option<&str>) -> String { match from { Some(from) => format!("{from}..{merged}"), None => format!("{merged}^1..{merged}^2"), } } static BROUGHT: LazyLock>>> = LazyLock::new(Mutex::default); /// The commits `range` names in `repo`, kept once git has found them: a /// merge never changes what it brought in. async fn brought_in(repo: &Repo, range: String) -> HashSet { if let Some(known) = BROUGHT.lock().unwrap().get(&range) { return known.clone(); } let listed = repo.git.text(&["rev-list", &range], None).await; let shas: HashSet = listed .iter() .flat_map(|l| l.lines()) .map(String::from) .collect(); if listed.is_ok() { BROUGHT.lock().unwrap().insert(range, shas.clone()); } shas } /// Whose claims `repo`'s pages believe, and where: its owner and /// collaborators first, so no pull's author can take their addresses. async fn vouched(app: &App, repo: &Repo) -> Vec { let record = repo.record(app).unwrap_or_default(); let entries = assembled(app, repo).await.unwrap_or_default(); let merged = forge::assemble(&record, &repo.did, &entries) .into_iter() .filter_map(|i| Some((i.author, brought(&i.merged?, i.merged_from.as_deref())))); let everywhere = std::iter::once(repo.did.clone()).chain(record.collaborators.into_iter().map(|c| c.did)); let people = everywhere .map(|did| (did, None)) .chain(merged.map(|(did, range)| (did, Some(range)))); futures::future::join_all(people.map(|(did, range)| async move { let only = match range { Some(range) => Some(brought_in(repo, range).await), None => None, }; let hashes = claims(app, &did).await; Vouched { did, hashes, only } })) .await } /// `commits`, as the pages show them, each with the accounts that wrote it /// where `repo`'s pages believe their claims (`vouched`). async fn shown(app: &App, repo: &Repo, commits: Vec) -> Vec { let people = vouched(app, repo).await; let mut out = Vec::new(); for c in commits { let mut helped = Vec::new(); for (name, email) in coauthors(&c.message) { helped.push(author(app, &people, &c.sha, name, &email).await); } let by = author(app, &people, &c.sha, c.author.name, &c.author.email).await; out.push(Commit { sha: c.sha, parents: c.parents, author: by, coauthors: helped, date: c.author.date, message: c.message, }); } out } /// What every page about a repository shows above itself. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct RepoHead { authority: String, /// The owner's profile picture, if it has one. avatar: Option, name: String, description: Option, fork_of: Option, /// The default branch. head: String, /// Open pulls and issues. pulls: usize, issues: usize, /// What its pages say of themselves to whatever unfurls a link to /// them, and the path of the picture they unfurl into. about: String, card: String, } /// A repository being shown. struct Repo { authority: String, name: String, did: String, key: String, git: Git, /// The default branch. head: String, /// Its record as the PDS has it, for a private repository the index /// never sees. space: Option, } impl Repo { async fn open(app: &App, authority: &str, name: &str) -> Result { let name = repo_name(name); let found = app.located(authority, name).await?; if found.moved { let now = format!("{}/{}", found.did, found.name); return Err(Shown(StatusCode::TEMPORARY_REDIRECT, now)); } if found.name != name { return Err(Shown(StatusCode::TEMPORARY_REDIRECT, found.name)); } let git = found.git; let head = objects::open(&git.dir) .map_err(AppError::internal)? .head() .unwrap_or_default(); Ok(Repo { authority: authority.to_string(), name: name.to_string(), did: found.did, key: found.key, git, head, space: found.space.then_some(found.record), }) } /// Its objects, read in process. Not held across an await: a reader /// belongs to one thread. fn objects(&self) -> Result { Ok(objects::open(&self.git.dir).map_err(AppError::internal)?) } fn href(&self, tail: &str) -> String { format!( "/{}/{}{tail}", url_path(&self.authority), url_path(&self.name) ) } fn uri(&self) -> String { forge::repo_uri(&self.did, &self.name) } /// The record, as the index has it, or the space for a private one. fn record(&self, app: &App) -> Option { if self.space.is_some() { return self.space.clone(); } let value = app.index.as_ref()?.repo(&self.uri()).ok()??; serde_json::from_value(value).ok() } /// Its open pulls and issues. async fn open_items(&self, app: &App) -> Option<(usize, usize)> { let entries = assembled(app, self).await.ok()?; let record = self.record(app).unwrap_or_default(); let items = forge::assemble(&record, &self.did, &entries); let open = |kind: &str| { items .iter() .filter(|i| i.kind == kind && i.state == "open") .count() }; Some((open("pull"), open("issue"))) } async fn head(&self, app: &App) -> RepoHead { let record = self.record(app); let fork_of = match record.as_ref().and_then(|r| r.fork_of.as_ref()) { Some(from) => repo_link(app, from).await, None => None, }; let open = self.open_items(app).await.unwrap_or_default(); let card = card_path(&self.href(""), &self.card(record.as_ref(), open)); let description = record.and_then(|r| r.description); let about = description.clone().unwrap_or_else(|| { format!( "A git repository in {}'s atmosphere account.", self.authority ) }); RepoHead { authority: self.authority.clone(), avatar: avatar(app, &self.did).await, name: self.name.clone(), description, fork_of, head: self.head.clone(), pulls: open.0, issues: open.1, about, card, } } /// Its card, as `record` and the counts of open pulls and issues say. fn card(&self, record: Option<&RepoRecord>, (pulls, issues): (usize, usize)) -> Card { Card::Repo { owner: self.authority.clone(), name: self.name.clone(), description: record.and_then(|r| r.description.clone()), pulls, issues, } } /// Splits `rest` into a revision and a path: the longest branch or tag /// that starts it, else its first segment. fn locate<'a>(&self, rest: &'a str) -> Result<(String, &'a str)> { let names = self.objects()?.names().map_err(AppError::internal)?; let longest = names .iter() .filter(|n| rest == n.as_str() || rest.starts_with(&format!("{n}/"))) .map(String::len) .max(); let cut = longest.unwrap_or(rest.find('/').unwrap_or(rest.len())); let rev = read::rev(Some(&rest[..cut])).map_err(not_found)?; Ok((rev, rest[cut..].trim_start_matches('/'))) } } #[derive(Deserialize, Default)] pub struct SearchQuery { q: Option, } /// A repository among others: whose, its name, and what it is. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Listed { owner: String, /// The owner's profile picture, if it has one. avatar: Option, name: String, description: Option, fork_of: Option, created_at: String, /// Whether it is private, in a space: shown only serving `--private`. private: bool, } #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Search { query: String, repos: Vec, } /// The repositories whose name or description holds `q`, newest first. async fn search(State(app): State>, Query(query): Query) -> Result { let index = app.index.as_ref().ok_or(Shown( StatusCode::NOT_FOUND, "This server keeps no index, so it knows no repositories to search.".into(), ))?; let q = query.q.unwrap_or_default().trim().to_string(); let found = index .repos(&q, 50) .map_err(anyhow::Error::from) .map_err(AppError::internal)?; let owners = futures::future::join_all(found.iter().map(|r| handle(&app, &r.did))).await; let avatars = futures::future::join_all(found.iter().map(|r| avatar(&app, &r.did))).await; let repos = found .into_iter() .zip(owners.into_iter().zip(avatars)) .map(|(r, (owner, avatar))| Listed { owner, avatar, name: r.name, description: r.description, fork_of: None, created_at: r.created_at, private: false, }) .collect(); Ok(answer(Search { query: q, repos })) } /// An account: who it is, as its Bluesky profile says if it has one, and /// its public repositories, read from its PDS, newest first. Serving /// `--private`, the account logged in sees its private ones too. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Profile { did: String, handle: String, name: Option, bio: Option, /// Its profile picture, as Bluesky's CDN serves it, if it has one. avatar: Option, /// Its bio on one line, for whatever unfurls a link to it. about: Option, repos: Vec, } async fn profile(State(app): State>, Path(authority): Path) -> Result { let at = RepoUrl { authority: authority.clone(), name: "_".into(), }; let remote = Remote::connect(&app.ctx, &at).await.map_err(not_found)?; let did = remote.did().to_string(); let shown = handle(&app, &did).await; let about = bsky_profile(&remote).await; let text = |field: &str| about[field].as_str().map(str::to_string); let name = text("displayName").filter(|n| !n.trim().is_empty()); let avatar = avatar_url(&did, &about); AVATARS.put(&did, avatar.clone()); let mut found = remote.list_repos().await.map_err(AppError::upstream)?; let mut private = Vec::new(); if app.private && let Ok(me) = session::me(&app.ctx).await && me.did == did { let mine = Remote::private_repos(&app.ctx, &me) .await .map_err(AppError::upstream)?; private = mine.iter().map(|r| r.rkey.clone()).collect(); found.extend(mine); } found.sort_by(|a, b| b.value.created_at.cmp(&a.value.created_at)); let forks = futures::future::join_all(found.iter().map(|r| async { match &r.value.fork_of { Some(from) => repo_link(&app, from).await, None => None, } })) .await; let repos = found .into_iter() .zip(forks) .map(|(r, fork_of)| Listed { private: private.contains(&r.rkey), owner: shown.clone(), avatar: avatar.clone(), name: r.value.name, description: r.value.description, fork_of, created_at: r.value.created_at, }) .collect(); Ok(answer(Profile { did, handle: shown, name, bio: text("description"), avatar, about: about["description"].as_str().map(|bio| summary(bio, 200)), repos, })) } type RepoPath = Path<(String, String)>; type RestPath = Path<(String, String, String)>; /// One entry of a directory. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Entry { name: String, /// `tree` or `blob`. kind: String, size: Option, } /// A README, as Markdown made into HTML or as text. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Readme { name: String, html: Option, text: Option, } /// A directory at a revision: its latest commit, its entries, folders /// first, and its README. Nothing at all, for a repository never pushed. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Listing { repo: RepoHead, rev: String, path: String, empty: bool, last: Option, entries: Vec, readme: Option, } async fn code(State(app): State>, Path((authority, name)): RepoPath) -> Result { let repo = Repo::open(&app, &authority, &name).await?; let head = repo.head.clone(); if repo.objects()?.tree("HEAD", "").is_err() { let empty = Listing { repo: repo.head(&app).await, rev: head, path: String::new(), empty: true, last: None, entries: Vec::new(), readme: None, }; return Ok(answer(empty)); } listing(&app, &repo, &head, "").await } async fn tree( State(app): State>, Path((authority, name, rest)): RestPath, ) -> Result { let repo = Repo::open(&app, &authority, &name).await?; let (rev, path) = repo.locate(&rest)?; listing(&app, &repo, &rev, path).await } async fn listing(app: &App, repo: &Repo, rev: &str, path: &str) -> Result { let head = repo.head(app).await; let o = repo.objects()?; let mut found = o.tree(rev, path).map_err(not_found)?; found.sort_by_key(|e| (e.kind != "tree", e.name.to_lowercase())); let last = o.log(rev, path, 0, 1).map_err(not_found)?; let prefix = if path.is_empty() { String::new() } else { format!("{path}/") }; let readme = match found .iter() .find(|e| e.kind == "blob" && e.name.to_lowercase().starts_with("readme")) { Some(readme) => { let bytes = o .blob(rev, &format!("{prefix}{}", readme.name)) .map_err(not_found)?; let text = String::from_utf8_lossy(&bytes).into_owned(); let dir = |view: &str| { repo.href(&format!("/{view}/{}/{}", url_path(rev), url_path(path))) .trim_end_matches('/') .to_string() }; let html = readme .name .to_lowercase() .ends_with(".md") .then(|| markdown(&text, &dir("blob"), &dir("raw"))); Some(Readme { name: readme.name.clone(), text: html.is_none().then_some(text), html, }) } None => None, }; let entries = found .into_iter() .map(|e| Entry { name: e.name, kind: e.kind, size: e.size, }) .collect(); Ok(answer(Listing { repo: head, rev: rev.to_string(), path: path.to_string(), empty: false, last: shown(app, repo, last).await.pop(), entries, readme, })) } /// Markdown, with raw HTML shown as text and links made relative to the /// directory it was found in. fn markdown(text: &str, blobs: &str, raws: &str) -> String { use pulldown_cmark::{Event, Options, Parser, Tag}; let options = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS; let events = Parser::new_ext(text, options).map(|event| match event { Event::Html(html) | Event::InlineHtml(html) => Event::Text(html), Event::Start(Tag::Link { link_type, dest_url, title, id, }) => Event::Start(Tag::Link { link_type, dest_url: link(&dest_url, blobs).into(), title, id, }), Event::Start(Tag::Image { link_type, dest_url, title, id, }) => Event::Start(Tag::Image { link_type, dest_url: link(&dest_url, raws).into(), title, id, }), other => other, }); let mut out = String::new(); pulldown_cmark::html::push_html(&mut out, events); out } /// A link from a README: web and mail links as they are, other schemes /// nowhere, and paths into the repository. fn link(dest: &str, base: &str) -> String { let scheme = dest .split_once(':') .map(|(s, _)| s) .filter(|s| !s.contains(['/', '#', '?'])); match scheme.map(str::to_ascii_lowercase).as_deref() { Some("http" | "https" | "mailto") => dest.to_string(), Some(_) => "#".to_string(), None if dest.starts_with(['#', '/']) => dest.to_string(), None => format!("{base}/{}", dest.trim_start_matches("./")), } } fn is_image(path: &str) -> Option<&'static str> { let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?; Some(match ext.as_str() { "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", "avif" => "image/avif", "svg" => "image/svg+xml", "ico" => "image/x-icon", _ => return None, }) } fn is_binary(bytes: &[u8]) -> bool { bytes[..bytes.len().min(8000)].contains(&0) || std::str::from_utf8(bytes).is_err() } /// One file at a revision: an image, a binary, too large to show, or text /// with the ranges its highlighting paints. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Blob { repo: RepoHead, rev: String, path: String, /// `image`, `binary`, `large` or `text`. shown: String, size: u64, lines: usize, text: Option, ranges: Option, } async fn blob( State(app): State>, Path((authority, name, rest)): RestPath, ) -> Result { let repo = Repo::open(&app, &authority, &name).await?; let (rev, path) = repo.locate(&rest)?; let bytes = repo.objects()?.blob(&rev, path).map_err(not_found)?; let text = String::from_utf8_lossy(&bytes); let shown = if is_image(path).is_some() { "image" } else if is_binary(&bytes) { "binary" } else if bytes.len() > INLINE { "large" } else { "text" }; let ranges = (shown == "text").then(|| { highlight::code(path, &text) .map(|r| r.encode()) .unwrap_or_default() }); Ok(answer(Blob { repo: repo.head(&app).await, path: path.to_string(), shown: shown.to_string(), size: bytes.len() as u64, lines: text.lines().count().max(1), text: ranges.is_some().then(|| text.into_owned()), ranges, rev, })) } /// The file itself. Never run as a page: sandboxed, and typed only as text /// or an image. async fn raw( State(app): State>, Path((authority, name, rest)): RestPath, ) -> Result { let repo = Repo::open(&app, &authority, &name).await?; let (rev, path) = repo.locate(&rest)?; let bytes = repo.objects()?.blob(&rev, path).map_err(not_found)?; let kind = match is_image(path) { Some(image) => image, None if is_binary(&bytes) => "application/octet-stream", None => "text/plain; charset=utf-8", }; Ok(( [ (header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, cache_for(&rev)), ( header::CONTENT_SECURITY_POLICY, "sandbox; default-src 'none'; style-src 'unsafe-inline'", ), (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), ], bytes, ) .into_response()) } #[derive(Deserialize, Default)] pub struct PageQuery { page: Option, } /// History, a page at a time, newest first. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct History { repo: RepoHead, rev: String, path: String, page: usize, commits: Vec, /// Whether there may be older commits than these. more: bool, } async fn commits( State(app): State>, Path((authority, name, rest)): RestPath, Query(q): Query, ) -> Result { let repo = Repo::open(&app, &authority, &name).await?; let (rev, path) = repo.locate(&rest)?; let page = q.page.unwrap_or(0); let log = repo .objects()? .log(&rev, path, page * COMMITS_PER_PAGE, COMMITS_PER_PAGE) .map_err(not_found)?; Ok(answer(History { repo: repo.head(&app).await, path: path.to_string(), page, more: log.len() == COMMITS_PER_PAGE, commits: shown(&app, &repo, log).await, rev, })) } /// One row of a file's diff: a hunk header, a note, or a line with its old /// and new numbers. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Row { /// `h`, `x`, `a`, `d`, or empty for context. kind: String, line: bool, old: Option, new: Option, text: String, } /// One file of a diff, or why its rows are not shown. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct FileDiff { old: String, new: String, /// `added`, `deleted`, `renamed` or `modified`. status: String, added: usize, removed: usize, note: Option, ranges: Option, rows: Vec, } fn file_diff(f: diff::File) -> FileDiff { let note = if f.binary { Some("A binary file, not shown.") } else if f.rows.is_empty() { Some(if f.status == Status::Renamed { "Moved without changes." } else { "No changes to the content." }) } else if f.rows.len() > DIFF_ROWS { Some("A large diff, not shown.") } else { None }; let path = if f.status == Status::Deleted { &f.old } else { &f.new }; let ranges = note .is_none() .then(|| highlight::rows(path, &f.rows).encode()); let rows = match note { Some(_) => Vec::new(), None => f.rows.into_iter().map(row).collect(), }; let status = match f.status { Status::Added => "added", Status::Deleted => "deleted", Status::Renamed => "renamed", Status::Modified => "modified", }; FileDiff { old: f.old, new: f.new, status: status.to_string(), added: f.added, removed: f.removed, note: note.map(str::to_string), ranges, rows, } } fn row(r: diff::Row) -> Row { let kind = match r.kind { Kind::Hunk => "h", Kind::Note => "x", Kind::Add => "a", Kind::Del => "d", Kind::Context => "", }; Row { kind: kind.to_string(), line: r.kind.is_line(), old: r.old, new: r.new, text: r.text, } } /// A commit and what it changed. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Change { repo: RepoHead, commit: Commit, files: Vec, } async fn commit( State(app): State>, Path((authority, name, rev)): RestPath, ) -> Result { let repo = Repo::open(&app, &authority, &name).await?; let rev = read::rev(Some(&rev)).map_err(not_found)?; let log = repo.objects()?.log(&rev, "", 0, 1).map_err(not_found)?; let c = shown(&app, &repo, log).await.remove(0); let patch = repo .git .text( &[ "-c", "core.quotePath=false", "show", "--format=", "--patch", "-M", "--no-color", "--no-ext-diff", &c.sha, ], None, ) .await .map_err(not_found)?; let files = diff::parse(&patch).into_iter().map(file_diff).collect(); Ok(answer(Change { repo: repo.head(&app).await, commit: c, files, })) } #[derive(Deserialize, Default)] pub struct StateQuery { state: Option, } async fn pulls( State(app): State>, Path((authority, name)): RepoPath, Query(q): Query, ) -> Result { items(app, authority, name, "pull", q).await } async fn issues( State(app): State>, Path((authority, name)): RepoPath, Query(q): Query, ) -> Result { items(app, authority, name, "issue", q).await } /// Everything known about the repository's pulls and issues: from the /// index, or for a private one from its members' repos in the space. async fn assembled(app: &App, repo: &Repo) -> Result> { if repo.space.is_some() { let url = RepoUrl { authority: repo.did.clone(), name: repo.name.clone(), }; let target = forge::Target::open(&app.ctx, &url) .await .map_err(AppError::upstream)?; return Ok(target.entries(&app.ctx).await.map_err(AppError::upstream)?); } let index = app.index.as_ref().ok_or(Shown( StatusCode::NOT_FOUND, "This server keeps no index, so it knows no pulls or issues.".into(), ))?; let entries = index .forge(&repo.did, &repo.key) .map_err(anyhow::Error::from) .map_err(AppError::internal)?; Ok(entries) } /// A pull or an issue in a list of them. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Summary { title: String, state: String, href: String, author: String, created_at: String, comments: usize, } /// A repository's pulls or issues, open or closed. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Items { repo: RepoHead, /// `pull` or `issue`. kind: String, /// Whether these are the open ones, rather than the closed. open: bool, opened: usize, closed: usize, items: Vec, } async fn items( app: Arc, authority: String, name: String, kind: &str, q: StateQuery, ) -> Result { let repo = Repo::open(&app, &authority, &name).await?; let entries = assembled(&app, &repo).await?; let record = repo.record(&app).unwrap_or_default(); let all: Vec = forge::assemble(&record, &repo.did, &entries) .into_iter() .filter(|i| i.kind == kind) .collect(); let open = q.state.as_deref() != Some("closed"); let (opened, closed): (Vec, Vec) = all.into_iter().partition(|i| i.state == "open"); let (counts, shown) = ( (opened.len(), closed.len()), if open { opened } else { closed }, ); let authors = futures::future::join_all(shown.iter().map(|i| handle(&app, &i.author))).await; let mut items = Vec::new(); for (i, author) in shown.into_iter().zip(authors) { let (did, _, rkey) = forge::parse_uri(&i.uri).map_err(not_found)?; items.push(Summary { href: repo.href(&format!("/{kind}/{did}/{rkey}")), title: i.title, state: i.state, author, created_at: i.created_at, comments: i.comments, }); } Ok(answer(Items { repo: repo.head(&app).await, kind: kind.to_string(), open, opened: counts.0, closed: counts.1, items, })) } /// Something said on a pull or an issue, made into HTML. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Said { author: String, /// The author's profile picture, if it has one. avatar: Option, created_at: String, html: String, /// `approve` or `changes`, for a review. verdict: Option, } /// A pull or an issue, and everything said on it. #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Discussion { repo: RepoHead, /// `pull` or `issue`. kind: String, title: String, state: String, author: String, /// The author's profile picture, if it has one. avatar: Option, created_at: String, /// A pull's target branch, and the repository and branch it brings. base: Option, from: Option, branch: Option, /// What it says, as its author opened it. body: Option, comments: Vec, /// The merge, for a merged pull. merged: Option, /// What it says of itself to whatever unfurls a link to it, and the /// path of the picture it unfurls into. about: String, card: String, } type ItemPath = Path<(String, String, String, String, String)>; /// A pull or an issue, by the kind its path names, and who opened it. async fn thread( app: &App, repo: &Repo, kind: &str, did: &str, rkey: &str, ) -> Result<(Thread, String)> { let collection = match kind { "pull" => knotless::records::nsid::PULL, "issue" => knotless::records::nsid::ISSUE, _ => { return Err(Shown( StatusCode::NOT_FOUND, format!("no such page: {kind}"), )); } }; let entries = assembled(app, repo).await?; let record = repo.record(app).unwrap_or_default(); let uri = format!("at://{did}/{collection}/{rkey}"); let thread = forge::thread(&record, &repo.did, &entries, &uri).map_err(not_found)?; let author = handle(app, &thread.item.author).await; Ok((thread, author)) } fn item_card(repo: &Repo, kind: &str, thread: &Thread, author: &str) -> Card { Card::Item { owner: repo.authority.clone(), name: repo.name.clone(), pull: kind == "pull", state: thread.item.state.clone(), title: thread.item.title.clone(), author: author.to_string(), comments: thread.comments.len(), } } async fn item( State(app): State>, Path((authority, name, kind, did, rkey)): ItemPath, ) -> Result { let repo = Repo::open(&app, &authority, &name).await?; let (thread, author) = self::thread(&app, &repo, &kind, &did, &rkey).await?; let page = repo.href(&format!("/{kind}/{did}/{rkey}")); let card = card_path(&page, &item_card(&repo, &kind, &thread, &author)); let Thread { item, comments, .. } = thread; let what = if kind == "pull" { "A pull" } else { "An issue" }; let about = match &item.body { Some(text) => summary(text, 200), None => format!("{what} on {}/{} by {author}.", repo.authority, repo.name), }; let from = match &item.head { Some(head) => repo_link(&app, &head.repo).await.map(|l| l.shown), None => None, }; let base = repo.href(""); let names = futures::future::join_all(comments.iter().map(|c| handle(&app, &c.author))).await; let avatars = futures::future::join_all(comments.iter().map(|c| avatar(&app, &c.author))).await; let said = comments .iter() .zip(names.into_iter().zip(avatars)) .map(|(c, (author, avatar))| Said { author, avatar, created_at: c.value["createdAt"].as_str().unwrap_or_default().into(), html: markdown(c.value["body"].as_str().unwrap_or_default(), &base, &base), verdict: c.value["verdict"].as_str().map(str::to_string), }) .collect(); Ok(answer(Discussion { repo: repo.head(&app).await, kind, body: item.body.as_deref().map(|b| markdown(b, &base, &base)), title: item.title, state: item.state, author, avatar: avatar(&app, &item.author).await, created_at: item.created_at, from, branch: item.head.map(|h| h.branch), base: item.base, comments: said, merged: item.merged, about, card, })) } static CARDS: LazyLock = LazyLock::new(|| card::Cache::new(512)); #[derive(Deserialize, Default)] pub struct CardQuery { v: Option, } /// A card as a PNG: cached for good under the version its page names, and /// checked again under any other, as it may have changed since. async fn card_png(card: Card, asked: Option, headers: &HeaderMap) -> Response { let svg = card.svg(); let version = card::version(&svg); let tag = format!("\"{version}\""); let cache = if asked.as_deref() == Some(version.as_str()) { "public, max-age=31536000, immutable" } else { "no-cache" }; let mut res = if headers .get(header::IF_NONE_MATCH) .is_some_and(|v| v.as_bytes() == tag.as_bytes()) { StatusCode::NOT_MODIFIED.into_response() } else { let png = CARDS.png(&version, svg).await; let mut res = axum::body::Body::from(axum::body::Bytes::from_owner(png)).into_response(); res.headers_mut() .insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png")); res }; let h = res.headers_mut(); h.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); h.insert(header::ETAG, HeaderValue::from_str(&tag).unwrap()); res } async fn home_card(headers: HeaderMap, Query(q): Query) -> Response { card_png(Card::Home, q.v, &headers).await } async fn repo_card( State(app): State>, Path((authority, name)): RepoPath, headers: HeaderMap, Query(q): Query, ) -> Result { let repo = Repo::open(&app, &authority, &name).await?; let open = repo.open_items(&app).await.unwrap_or_default(); let card = repo.card(repo.record(&app).as_ref(), open); Ok(card_png(card, q.v, &headers).await) } async fn item_card_png( State(app): State>, Path((authority, name, kind, did, rkey)): ItemPath, headers: HeaderMap, Query(q): Query, ) -> Result { let repo = Repo::open(&app, &authority, &name).await?; let (thread, author) = self::thread(&app, &repo, &kind, &did, &rkey).await?; let card = item_card(&repo, &kind, &thread, &author); Ok(card_png(card, q.v, &headers).await) } #[cfg(test)] mod tests { use super::*; #[test] fn what_merges_brought() { assert_eq!(brought("m", Some("f")), "f..m"); assert_eq!(brought("m", None), "m^1..m^2"); } #[test] fn coauthor_trailers() { let message = "Fix it\n\nCo-authored-by: Claude \nSigned-off-by: A \nco-authored-by: B < b@x > \nCo-authored-by: nobody\n"; let named = |n: &str, e: &str| (n.to_string(), e.to_string()); assert_eq!( coauthors(message), [named("Claude", "noreply@anthropic.com"), named("B", "b@x")] ); assert!(coauthors("Co-authored-by: A ").is_empty()); assert!(coauthors("Subject\n\nCo-authored-by: A x\n\n[a](b.md) ![i](i.png) t", "/B", "/R", ); assert!( html.contains("<script>") && html.contains("<b>"), "{html}" ); assert!( html.contains(r#"href="/B/b.md""#) && html.contains(r#"src="/R/i.png""#), "{html}" ); } #[test] fn summaries() { assert_eq!(summary(" one\n\ntwo ", 200), "one two"); assert_eq!(summary("one two three", 8), "one two…"); assert_eq!(summary("ab", 2), "ab"); } #[test] fn small_things() { assert_eq!(url_path("a b/c#d?é"), "a%20b/c%23d%3F%C3%A9"); assert_eq!(is_image("a/B.PNG"), Some("image/png")); assert_eq!(is_image("Makefile"), None); assert!(is_binary(&[0, 1]) && is_binary(&[0xff, 0xfe]) && !is_binary(b"text")); } #[test] fn files_of_a_diff() { let file = |status, binary, rows: usize| diff::File { old: "a.rs".into(), new: "b.rs".into(), status, binary, rows: (0..rows) .map(|i| diff::Row { kind: Kind::Add, old: None, new: Some(i as u32), text: "x".into(), }) .collect(), added: rows, removed: 0, }; let note = |f| file_diff(f).note.unwrap_or_default(); assert_eq!( note(file(Status::Renamed, false, 0)), "Moved without changes." ); assert_eq!( note(file(Status::Modified, false, 0)), "No changes to the content." ); assert_eq!( note(file(Status::Added, true, 0)), "A binary file, not shown." ); let large = file_diff(file(Status::Deleted, false, DIFF_ROWS + 1)); assert_eq!(large.note.as_deref(), Some("A large diff, not shown.")); assert!(large.rows.is_empty() && large.ranges.is_none()); assert_eq!(large.status, "deleted"); let row = |kind, old, new, text: &str| diff::Row { kind, old, new, text: text.into(), }; let mut mixed = file(Status::Modified, false, 0); mixed.rows = vec![ row(Kind::Hunk, None, None, "@@ -1,2 +1,2 @@"), row(Kind::Context, Some(1), Some(1), "a"), row(Kind::Del, Some(2), None, "b"), row(Kind::Note, None, None, "\\ No newline at end of file"), row(Kind::Add, None, Some(2), "c"), ]; let shown = file_diff(mixed); let kinds: Vec<(&str, bool)> = shown .rows .iter() .map(|r| (r.kind.as_str(), r.line)) .collect(); assert_eq!( kinds, [ ("h", false), ("", true), ("d", true), ("x", false), ("a", true) ] ); assert!(shown.note.is_none() && shown.ranges.is_some()); assert_eq!(file_diff(file(Status::Added, false, 1)).status, "added"); assert_eq!(file_diff(file(Status::Renamed, false, 1)).status, "renamed"); } }