Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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);
    }
}