crates/gunnar-ui-server/src/api/readme.rs
//! **`GET /api/repos/{namespace}/{name}/readme?as=` — the repository's front
//! page: typeset (`svg`, the default) or plain (`?as=html`).**
//!
//! The tree walk is the same one [`crate::api::browse`] does, through the same
//! [`crate::git::Repository`], with the same per-request object budget and the
//! same [`crate::api::Reader`] credential. Nothing here has its own way of
//! reading a repository — a second one would be a second place for the
//! anonymous/admin fork to be got wrong. The rendering is
//! [`nornir_docbook`]'s; what is this module's is the part that is a *forge's*
//! problem: which file is the README, which blobs its images are, and what to
//! answer when any of it fails.
//!
//! # The BFF makes no outbound request for a README, ever
//!
//! Images are resolved **against the tree, at the same revision**, fetched as
//! blobs through gunnar, and handed to docbook in an [`ImageSet`] — its
//! in-memory door, added in 0.1.1 for exactly this caller. docbook's other two
//! doors are a mounted filesystem root and an HTTP fetch for absolute URLs;
//! neither is opened here, and the `ImageSet` is consulted before both, so an
//! absolute URL the caller happens to have bytes for is still not fetched.
//!
//! A console that fetched what a README pointed at would be a server-side
//! request forger with a repository as its input, reachable by an anonymous
//! visitor, on a box whose whole posture is that it has no outbound network.
//!
//! # Why the route is on the anonymous allow-list
//!
//! A public repository's README is the most public thing about it: it is what a
//! stranger came to read, and gunnar already answers `Refs.Log` and
//! `Refs.ReadObject` for that repository to the guest key. Adding `readme` to
//! `is_anonymous_read` adds no new gunnar call, only a cheaper way to spell the
//! ones already allowed. A private repository is refused at the same place
//! every other read of it is — by gunnar, against the guest principal's
//! (absent) grant — and `crate::anonymous` asserts both halves.
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::Deserialize;
use crate::api::render::{self, Document};
use crate::api::{ApiResult, Reader};
use crate::app::AppState;
use crate::git::{self, Repository, MAX_BLOB_BYTES};
use crate::readme::{self, Readme, Shape};
/// `?ref=&as=`
#[derive(Debug, Clone, Deserialize)]
pub struct At {
/// A reference name or an object id. Empty means the default branch.
///
/// `ref` on the wire, `reference` in Rust — `ref` is a keyword.
#[serde(default, rename = "ref")]
pub reference: String,
/// `svg` (the default) or `html`. An unknown value is the default rather
/// than a 400: this names a rendering, and a console asking for a shape
/// this build does not have should get a page, not an error.
///
/// `as` on the wire, `as_shape` in Rust — `as` is a keyword.
#[serde(default, rename = "as")]
pub as_shape: Option<String>,
}
impl At {
fn shape(&self) -> Shape {
match self.as_shape.as_deref() {
Some("html") => Shape::Html,
_ => Shape::Svg,
}
}
}
/// `GET /api/repos/{namespace}/{name}/readme?ref=&as=`
pub async fn read(
reader: Reader,
State(state): State<AppState>,
Path((namespace, name)): Path<(String, String)>,
Query(at): Query<At>,
) -> ApiResult<Readme> {
let shape = at.shape();
let repo = Repository::open(&reader.gunnar, &namespace, &name, state.objects.clone()).await?;
let revision = repo.resolve(&at.reference).await?;
let root = repo.read_tree(&revision.tree_oid).await?;
let Some(entry) = pick(&root) else {
return Ok(Json(Readme::none()));
};
let path = entry.name.clone();
let object = repo.read_object(&entry.oid, MAX_BLOB_BYTES).await?;
let oid = object.oid.clone();
// Not text at all: there is nothing to typeset and nothing to sanitise.
// `text` with an empty body is the honest grade — the browse view is where
// a reader goes for the file itself.
if git::looks_binary(&object.data, object.truncated) {
return Ok(Json(Readme {
kind: "text",
body: String::new(),
path,
oid,
warning: Some("the README is not text".to_owned()),
}));
}
let source = String::from_utf8_lossy(&object.data).into_owned();
if !readme::is_markdown(&path) {
return Ok(Json(Readme {
kind: "text",
body: source,
path,
oid,
warning: None,
}));
}
// **Everything from here is `api::render`'s**, and that is the point: a
// README is one markdown file in a tree that this module happened to pick,
// so the cache, the bounded typeset queue, the image fetch, the sanitiser
// and the link rewriter are the same ones any other file goes through. What
// stayed here is the only question that is a README's: which file is it.
Ok(Json(
render::page(
&state,
&repo,
&revision.tree_oid,
Document {
namespace: &namespace,
name: &name,
reference: &at.reference,
path: &path,
oid: &oid,
},
&source,
shape,
)
.await?,
))
}
/// The first candidate the tree root actually holds.
///
/// Ordered by [`readme::CANDIDATES`] and not by the tree, so a repository with
/// both `README.md` and `readme.md` draws the same one every time.
fn pick(root: &[git::Entry]) -> Option<&git::Entry> {
readme::CANDIDATES.iter().find_map(|candidate| {
root.iter()
.find(|entry| !entry.is_dir() && entry.kind != "submodule" && entry.name == *candidate)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(name: &str, kind: &'static str) -> git::Entry {
git::Entry {
name: name.to_owned(),
kind,
oid: format!("oid-{name}"),
mode: "100644".to_owned(),
lossy_name: false,
}
}
#[test]
fn the_first_candidate_present_wins_whatever_the_tree_order_is() {
let root = vec![
entry("readme.md", "file"),
entry("zzz", "file"),
entry("README.md", "file"),
];
assert_eq!(pick(&root).expect("a readme").name, "README.md");
}
#[test]
fn a_directory_called_readme_is_not_a_readme() {
assert!(pick(&[entry("README.md", "dir")]).is_none());
}
#[test]
fn a_tree_with_no_readme_has_none() {
assert!(pick(&[entry("src", "dir"), entry("Cargo.toml", "file")]).is_none());
}
#[test]
fn a_bare_readme_is_found_last() {
let root = vec![entry("README", "file"), entry("README.markdown", "file")];
assert_eq!(pick(&root).expect("a readme").name, "README.markdown");
}
/// The query parameter names a rendering, so an unknown one is the default
/// rather than a 400 at somebody who typed a URL — and the default is the
/// typeset page now that it carries its own text.
#[test]
fn the_shape_defaults_to_svg_and_only_html_changes_it() {
let at = |value: Option<&str>| At {
reference: String::new(),
as_shape: value.map(str::to_owned),
};
assert_eq!(at(None).shape(), Shape::Svg);
assert_eq!(at(Some("svg")).shape(), Shape::Svg);
assert_eq!(at(Some("html")).shape(), Shape::Html);
assert_eq!(at(Some("pdf")).shape(), Shape::Svg);
}
}