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/public_site.rs

//! **gunnar.rs's public face, mounted** (feature `public-site`, off by default).
//!
//! The owner's SLIM RULE (2026-10-25): the site is the `gunnar-public-site`
//! crate, and this file is all the console adds for it. It does three things:
//!
//! 1. fills in the crate's `Source` with calls this console ALREADY makes for a
//!    visitor with no session (`Repos.List`, `Repos.Get`, `Refs.Log` and the
//!    object reads) and gunnar's existing issue reads (`Forge.ListIssues`,
//!    `Forge.GetIssue`; PUB-ISSUES), every one signed by the grantless GUEST key ([`App::guest`])
//!    and never by the console's own. gunnar narrows each answer to the public
//!    subset on its side of the wire; the crate drops any row not marked public
//!    as well. gunnar-server gets nothing new;
//! 2. mounts the crate's layer OUTSIDE `require_auth` ([`mount`]), so a built
//!    page is served to a visitor and everything else reaches the console as it
//!    does today;
//! 3. registers the administrator's switch, `GET`/`PUT /api/public-site` ([`routes`]),
//!    kept as gunnar's generic setting `console.public-site`
//!    ([`crate::console_setting`], owner 2026-10-26): only an administrator flips it, each
//!    flip is audited, and the stored position outlives a console restart.
//!
//! Two settings, both in the environment file's table (`envfile`):
//! `GUNNAR_UI_PUBLIC_SITE` (the switch's INITIAL position: OFF unless it says `on`; the
//! stored setting wins once an administrator has flipped it) and
//! `GUNNAR_UI_PUBLIC_AI_CRAWLERS` (`deny` turns the AI-crawler allow off;
//! the default is ALLOW). The site's only outbound call (IndexNow's ping, [`Ureq`]) has its
//! own setting, IndexNow's key, kept as gunnar's generic setting `console.public-site.indexnow`
//! and set by an administrator (`PUT /api/public-site/indexnow`, A32-16);
//! `GUNNAR_UI_PUBLIC_INDEXNOW_KEY` is only its initial value. No key, as in the default airgap
//! install, and nothing ever leaves the box.
//!
//! [`App::guest`]: crate::app::App::guest

use std::collections::VecDeque;
use std::sync::Arc;

use axum::extract::Extension;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use gunnar_public_site as site;
use http::StatusCode;
use serde_json::json;

use crate::app::AppState;
use crate::auth::middleware::Caller;
use crate::git::{ObjectCache, Repository};
use crate::grpc::Gunnar;
use crate::pb;

/// The README names looked for at a repository's root, in this order.
const README_NAMES: &[&str] = &["README.md", "readme.md", "Readme.md", "README.markdown", "README", "README.txt"];

/// The public data, read as the grantless guest. No guest key: nothing is
/// public, and the site builds its pages from no repositories.
pub struct GuestSource {
    guest: Option<Gunnar>,
    objects: Arc<ObjectCache>,
    /// The console, for the home's offer: the ONE box's, through the shared
    /// buy flow (`metered` only; the open build has no offer to show).
    #[cfg(feature = "metered")]
    state: Option<AppState>,
}

impl GuestSource {
    pub fn new(guest: Option<Gunnar>) -> GuestSource {
        GuestSource {
            guest,
            objects: Arc::new(ObjectCache::default()),
            #[cfg(feature = "metered")]
            state: None,
        }
    }

    /// The source for this console: its guest key, and (metered) its shop.
    pub fn of(state: &AppState) -> GuestSource {
        #[cfg_attr(not(feature = "metered"), allow(unused_mut))]
        let mut s = GuestSource::new(state.guest.clone());
        #[cfg(feature = "metered")]
        {
            s.state = Some(state.clone());
        }
        s
    }

    fn guest(&self) -> Result<&Gunnar, String> {
        self.guest.as_ref().ok_or_else(|| "no guest identity is configured".to_owned())
    }
}

fn path(repo: &site::RepoRef) -> pb::RepoPath {
    crate::api::repo_path(&repo.namespace, &repo.name)
}

impl site::Source for GuestSource {
    fn public_repos(&self) -> site::BoxFuture<'_, Result<Vec<site::RepoRef>, String>> {
        Box::pin(async move {
            let guest = self.guest()?;
            let mut out = Vec::new();
            let mut token = String::new();
            // Bounded: at most 20 pages, whatever gunnar's page size is.
            for _ in 0..20 {
                let page = guest
                    .repos()
                    .list(pb::ListReposRequest { page_size: 500, page_token: token.clone(), ..Default::default() })
                    .await
                    .map_err(|s| s.message().to_owned())?
                    .into_inner();
                for repo in page.repos {
                    let public = repo.visibility == pb::Visibility::Public as i32;
                    if let Some(p) = repo.path {
                        out.push(site::RepoRef { namespace: p.namespace, name: p.repo, description: repo.description, public });
                    }
                }
                if page.next_page_token.is_empty() {
                    break;
                }
                token = page.next_page_token;
            }
            Ok(out)
        })
    }

    fn head<'a>(&'a self, repo: &'a site::RepoRef) -> site::BoxFuture<'a, Result<Option<String>, String>> {
        Box::pin(async move {
            let guest = self.guest()?;
            match guest
                .refs()
                .log(pb::LogRequest { repo: Some(path(repo)), start: String::new(), page_size: 1, ..Default::default() })
                .await
            {
                Ok(log) => Ok(log.into_inner().commits.into_iter().next().map(|c| c.oid)),
                Err(s) if s.code() == tonic::Code::NotFound => Ok(None),
                Err(s) => Err(s.message().to_owned()),
            }
        })
    }

    fn snapshot<'a>(&'a self, repo: &'a site::RepoRef, limits: &'a site::Limits) -> site::BoxFuture<'a, Result<site::RepoSnapshot, String>> {
        Box::pin(async move { snapshot(self.guest()?, &self.objects, repo, limits).await })
    }

    /// **The home's offer** (`metered`, S-1): the account sizes the ONE box
    /// offers a new account, asked through the shared buy flow's own read
    /// (`signup_plans`) on the refresh timer, in monetize's words. A console
    /// that sells nothing here, or a shop that does not answer, shows none.
    /// **A public repository's issues** (PUB-ISSUES): gunnar's `Forge.ListIssues`, asked as
    /// the grantless guest, so gunnar answers it only for a repository it calls public (Read
    /// on a public repository is everybody's) and refuses every other. No author is carried.
    fn issues<'a>(&'a self, repo: &'a site::RepoRef) -> site::BoxFuture<'a, Result<Vec<site::Issue>, String>> {
        Box::pin(async move {
            let listed = self.guest()?.forge().list_issues(pb::ListIssuesRequest { repo: repo.path() }).await.map_err(|s| s.message().to_owned())?;
            Ok(listed
                .into_inner()
                .issues
                .into_iter()
                .map(|i| site::Issue { number: i.number, title: i.title, body: i.body, open: i.open, opened_at_unix: i.opened_at_unix_ms.div_euclid(1000), comments: i.comments, thread: None })
                .collect())
        })
    }

    /// One issue's thread after its opening (`Forge.GetIssue`, as the guest): its comments and
    /// its close with the reason, oldest first. Nobody's name is carried.
    fn issue_thread<'a>(&'a self, repo: &'a site::RepoRef, number: u64) -> site::BoxFuture<'a, Result<Vec<site::IssueNote>, String>> {
        Box::pin(async move {
            let got = self.guest()?.forge().get_issue(pb::GetIssueRequest { repo: repo.path(), number }).await.map_err(|s| s.message().to_owned())?;
            Ok(got.into_inner().events.into_iter().filter_map(|signed| note_of(signed.event?)).collect())
        })
    }

    #[cfg(feature = "metered")]
    fn offer(&self) -> site::BoxFuture<'_, Option<site::Offer>> {
        Box::pin(async move {
            let state = self.state.as_ref().filter(|s| s.sells_here())?;
            let relay = crate::auth::buy_site::relay(state)?;
            let ask = monetize_site_buy::relay::Ask::new(http::HeaderMap::new(), "public-site");
            let answer = monetize_site_buy::relay::signup_plans(&relay, &ask, None).await;
            if answer.status != StatusCode::OK {
                return None;
            }
            offer_of(&answer.body)
        })
    }
}

/// One public repository's bounded snapshot: its newest commit (ONE `Refs.Log` row),
/// the tree breadth first up to the limits, and the README. No history walk, no blame, no
/// diff, no archive, and no file's text (GUN-GUEST: a file's page is the console's,
/// pre-rendered on demand, [`crate::prerender`]).
async fn snapshot(guest: &Gunnar, objects: &Arc<ObjectCache>, repo: &site::RepoRef, limits: &site::Limits) -> Result<site::RepoSnapshot, String> {
    let record = guest
        .repos()
        .get(pb::GetRepoRequest { path: Some(path(repo)) })
        .await
        .map_err(|s| s.message().to_owned())?
        .into_inner()
        .repo
        .unwrap_or_default();
    // A second check beside gunnar's, as `/login/repos` makes: only a row
    // gunnar itself calls public is read.
    if record.visibility != pb::Visibility::Public as i32 {
        return Err("not public".into());
    }
    let log = guest
        .refs()
        .log(pb::LogRequest { repo: Some(path(repo)), start: String::new(), page_size: 1, ..Default::default() })
        .await
        .map_err(|s| s.message().to_owned())?
        .into_inner();
    let Some(newest) = log.commits.first() else {
        return Ok(site::RepoSnapshot { tree_complete: true, ..Default::default() });
    };
    let head = site::Commit {
        oid: newest.oid.clone(),
        subject: newest.message.lines().next().unwrap_or_default().to_owned(),
        at_unix: newest.committer.as_ref().or(newest.author.as_ref()).map(|s| s.time_unix_seconds).unwrap_or_default(),
    };
    let mut out = site::RepoSnapshot { head: Some(head), ..Default::default() };
    let git = Repository::open(guest, &repo.namespace, &repo.name, objects.clone()).await.map_err(|e| e.0.message)?;

    // The tree, breadth first.
    let mut queue: VecDeque<(String, String)> = VecDeque::from([(String::new(), newest.tree_oid.clone())]);
    let mut reads = 0;
    let mut readme: Option<(String, String)> = None;
    let mut complete = true;
    while let Some((dir, oid)) = queue.pop_front() {
        if reads >= limits.max_tree_reads {
            complete = false;
            break;
        }
        reads += 1;
        let Ok(entries) = git.read_tree(&oid).await else {
            complete = false;
            continue;
        };
        for e in entries {
            if e.lossy_name {
                continue;
            }
            if out.tree.len() >= limits.max_tree_entries {
                complete = false;
                break;
            }
            let full = if dir.is_empty() { e.name.clone() } else { format!("{dir}/{}", e.name) };
            out.tree.push(site::TreeEntry { path: full.clone(), dir: e.is_dir() });
            if e.is_dir() {
                queue.push_back((full, e.oid));
            } else if dir.is_empty() && matches!(e.kind, "file" | "executable") {
                let rank = README_NAMES.iter().position(|n| *n == full);
                let better = rank.is_some_and(|r| readme.as_ref().is_none_or(|(p, _)| README_NAMES.iter().position(|n| n == p).is_some_and(|q| r < q)));
                if better {
                    readme = Some((full, e.oid));
                }
            }
        }
    }
    out.tree_complete = complete && queue.is_empty();

    // An encrypted repository's bytes are envelopes: its names are listed and
    // nothing is opened.
    if record.encrypted {
        return Ok(out);
    }
    if let Some((name, oid)) = readme {
        if let Ok(o) = git.read_object(&oid, limits.max_readme_bytes).await {
            if let Some(text) = text_of(&o.data, o.truncated) {
                out.readme = Some((name, text));
            }
        }
    }
    Ok(out)
}

/// One forge event of an issue's thread as a public note: a comment, or the close with its
/// reason; the opening (the issue itself) and anything else is no note.
fn note_of(event: pb::ForgeEvent) -> Option<site::IssueNote> {
    use pb::forge_event::Kind;
    let at_unix = event.at_unix_ms.div_euclid(1000);
    match event.kind? {
        Kind::IssueCommented(c) => Some(site::IssueNote { at_unix, body: c.body, closed: false }),
        Kind::IssueClosed(c) => Some(site::IssueNote { at_unix, body: c.reason, closed: true }),
        _ => None,
    }
}

/// The home's offer from the buy flow's `signup_plans` answer
/// (`{plans:[{name, resources_words, price_words}]}`): monetize's words, as given.
#[cfg(feature = "metered")]
fn offer_of(body: &serde_json::Value) -> Option<site::Offer> {
    let words = |p: &serde_json::Value, k: &str| p.get(k).and_then(serde_json::Value::as_str).unwrap_or_default().trim().to_owned();
    let items: Vec<site::OfferItem> = body
        .get("plans")?
        .as_array()?
        .iter()
        .map(|p| site::OfferItem {
            title: words(p, "name"),
            lines: [words(p, "resources_words"), words(p, "price_words")].into_iter().filter(|l| !l.is_empty()).collect(),
        })
        .filter(|i| !i.title.is_empty())
        .collect();
    (!items.is_empty()).then(|| site::Offer { heading: "Plans".to_owned(), items })
}

/// A blob's text, or `None` for a binary, a cut-off one or a Git LFS pointer.
fn text_of(data: &[u8], truncated: bool) -> Option<String> {
    if crate::git::looks_binary(data, truncated) {
        return None;
    }
    let text = String::from_utf8_lossy(data).into_owned();
    if text.starts_with("version https://git-lfs.github.com/spec/") {
        return None;
    }
    Some(text)
}

/// The site's words and bounds for this console: its public origin is the
/// passkey origin, its clone base `GUNNAR_UI_GIT_URL`.
pub fn config(state: &AppState) -> site::Config {
    let base = state.webauthn.as_ref().map(|w| w.origin().to_owned()).unwrap_or_else(|| "http://localhost".to_owned());
    let mut c = site::Config::gunnar_from_env(&base);
    c.git_url = crate::auth::routes::git_url().map(str::to_owned);
    // No namespace is published under a first segment the console or the server answers
    // itself (`crate::address::RESERVED`: its routes, its prefixes, every route a build adds).
    c.reserved.extend(crate::address::RESERVED.iter().filter(|p| !p.is_empty()).map(|p| (*p).to_owned()));
    c.reserved.sort();
    c.reserved.dedup();
    #[cfg(feature = "metered")]
    {
        c.signup = state.sells_here();
    }
    // PUB-HOME (owner 2026-10-29): `/` is the console's own landing, the page a
    // visitor got before the site was on, with only the search tags added to it.
    // GUN-LANDING (owner 2026-10-30): with Sign up beside Log in where this console sells.
    c.landing = Some(crate::auth::routes::landing_html(c.signup));
    c
}

/// The site for this console, its pages refreshed on the timer once a
/// visitor first asks.
pub fn site(state: &AppState) -> Arc<site::Site> {
    let signed_in = {
        let state = state.clone();
        // A session cookie is what tells a signed-in visitor apart here: the
        // public site picks its variant without a call to the identity core per
        // page (a stale cookie meets the console's gate, which asks the core).
        let _ = &state;
        Arc::new(move |h: &http::HeaderMap| crate::auth::session_cookie(h).is_some())
    };
    let on = site::switch_starts_on(std::env::var(site::ENV_SWITCH).ok().as_deref());
    // The refresher starts with the first visitor's request for a site page
    // (`Site::start_on_first_ask`), never with the router: a console nobody
    // visits asks gunnar nothing on the guest key.
    // The pinger is only ever called when the config carries an IndexNow key.
    let pinger: Option<Arc<dyn site::Pinger>> = Some(Arc::new(Ureq));
    // OFF until gunnar has said what it stores (RA2-1: the switch fails closed); the
    // environment's position is the initial one, taken only where nothing is stored.
    let site = site::Site::new(config(state), Arc::new(GuestSource::of(state)), site::Hooks { signed_in, pinger }, false);
    // The stored position, where an administrator has flipped it, wins over the environment's.
    let adopted = site.clone();
    crate::console_setting::follow(state, crate::console_setting::PUBLIC_SITE, on, move |on| adopted.switch().set(on));
    // IndexNow's key (A32-16): gunnar's generic setting, OFF until gunnar has answered; the
    // environment's key is only the initial one, taken while nothing is stored.
    let initial = std::env::var(site::ENV_INDEXNOW_KEY).ok();
    let keyed = site.clone();
    crate::console_setting::follow_value(
        state,
        crate::console_setting::INDEXNOW,
        |stored| stored.as_ref().is_some_and(|b| !b.is_empty() && key_of(b).is_none()),
        move |last| indexnow_key(initial.as_deref(), last),
        move |key: Option<String>| keyed.set_indexnow(key.as_deref().and_then(|k| site::indexnow_from(Some(k)))),
    );
    site
}

/// Pure: stored bytes as an IndexNow key, where they are one.
fn key_of(bytes: &[u8]) -> Option<String> {
    let key = std::str::from_utf8(bytes).ok()?;
    site::indexnow_from(Some(key)).map(|ix| ix.key)
}

/// Pure: **the IndexNow key in effect** (A32-16), by the switches' rules: nothing read yet is
/// OFF; nothing stored is the environment's initial key (where it is one); a stored key is the
/// key, and an empty or damaged stored value is OFF.
pub fn indexnow_key(initial: Option<&str>, last: Option<&Option<Vec<u8>>>) -> Option<String> {
    match last {
        None => None,
        Some(None) => site::indexnow_from(initial).map(|ix| ix.key),
        Some(Some(bytes)) => key_of(bytes),
    }
}

/// **IndexNow's ping**, the site's one outbound call, through the console's one
/// HTTP client (ureq, blocking, so on `spawn_blocking`). Called by the site only
/// while it holds an IndexNow key (the stored setting `console.public-site.indexnow`, or
/// `GUNNAR_UI_PUBLIC_INDEXNOW_KEY` while nothing is stored).
pub struct Ureq;

impl site::Pinger for Ureq {
    fn post<'a>(&'a self, endpoint: &'a str, json: String) -> site::BoxFuture<'a, Result<u16, String>> {
        let endpoint = endpoint.to_owned();
        Box::pin(async move {
            tokio::task::spawn_blocking(move || {
                let agent = ureq::Agent::config_builder()
                    .timeout_global(Some(std::time::Duration::from_secs(20)))
                    .proxy(None)
                    .max_redirects(0)
                    .http_status_as_error(false)
                    .build()
                    .new_agent();
                agent
                    .post(&endpoint)
                    .header("Content-Type", "application/json; charset=utf-8")
                    .send(&json)
                    .map(|r| r.status().as_u16())
                    .map_err(|e| e.to_string())
            })
            .await
            .map_err(|e| e.to_string())?
        })
    }
}

/// The administrator's switch: `GET /api/public-site` says whether it is on,
/// `PUT /api/public-site {"on": bool}` flips it: stored first as gunnar's generic setting
/// (`console.public-site`), then applied to this running console.
pub fn routes(app: Router<AppState>, s: &Arc<site::Site>) -> Router<AppState> {
    let read = {
        let s = s.clone();
        move || async move { Json(json!({ "on": s.switch().is_on(), "ai_crawlers": s.config().ai_crawlers, "indexnow": s.indexnow().is_some() })) }
    };
    let set_key = {
        let s = s.clone();
        move |axum::extract::State(state): axum::extract::State<AppState>, caller: Option<Extension<Caller>>, Json(body): Json<serde_json::Value>| async move {
            set_indexnow(&state, &s, caller.map(|c| c.0), &body).await
        }
    };
    let write = {
        let s = s.clone();
        move |axum::extract::State(state): axum::extract::State<AppState>, caller: Option<Extension<Caller>>, Json(body): Json<serde_json::Value>| async move {
            flip(&state, &s, caller.map(|c| c.0), &body).await
        }
    };
    app.route("/api/public-site", get(read).put(write)).route("/api/public-site/indexnow", axum::routing::put(set_key))
}

/// `PUT /api/public-site/indexnow {"key": "<key>" | ""}` (A32-16): an administrator sets or
/// clears IndexNow's key, stored first as gunnar's generic setting `console.public-site.indexnow`,
/// then applied to the running site, and audited.
async fn set_indexnow(state: &AppState, s: &site::Site, caller: Option<Caller>, body: &serde_json::Value) -> Response {
    use crate::console_setting as setting;
    if !setting::may_flip(caller.as_ref()) {
        return (StatusCode::FORBIDDEN, "only an administrator sets the IndexNow key").into_response();
    }
    let Some(key) = body.get("key").and_then(serde_json::Value::as_str).map(str::trim) else {
        return (StatusCode::BAD_REQUEST, "send {\"key\": \"<your IndexNow key>\"}, or {\"key\": \"\"} to clear it").into_response();
    };
    let ix = if key.is_empty() { None } else { site::indexnow_from(Some(key)) };
    if !key.is_empty() && ix.is_none() {
        return (StatusCode::BAD_REQUEST, "an IndexNow key is 8 to 128 of a-z, A-Z, 0-9 and -").into_response();
    }
    let before = match setting::write_value(state, setting::INDEXNOW, key.as_bytes()).await {
        Ok(before) => before,
        Err(why) => return (StatusCode::BAD_GATEWAY, format!("the IndexNow key could not be stored at Gunnar, so it was not set: {why}")).into_response(),
    };
    setting::flipped(setting::INDEXNOW);
    let on = ix.is_some();
    s.set_indexnow(ix);
    setting::audit(setting::INDEXNOW, &setting::who(caller.as_ref()), before.map(|b| !b.is_empty()), on);
    Json(json!({ "indexnow": on })).into_response()
}

async fn flip(state: &AppState, s: &site::Site, caller: Option<Caller>, body: &serde_json::Value) -> Response {
    use crate::console_setting as setting;
    if !setting::may_flip(caller.as_ref()) {
        return (StatusCode::FORBIDDEN, "only an administrator switches the public site").into_response();
    }
    let Some(on) = body.get("on").and_then(serde_json::Value::as_bool) else {
        return (StatusCode::BAD_REQUEST, "send {\"on\": true} or {\"on\": false}").into_response();
    };
    // Stored first: a switch gunnar did not keep is not flipped, so the console never says a
    // position the next restart would lose.
    let before = match setting::write(state, setting::PUBLIC_SITE, on).await {
        Ok(before) => before,
        Err(why) => return (StatusCode::BAD_GATEWAY, format!("the switch could not be stored at Gunnar, so it was not flipped: {why}")).into_response(),
    };
    // Said once stored: a read of gunnar that was on its way meanwhile is older than this flip.
    setting::flipped(setting::PUBLIC_SITE);
    s.switch().set(on);
    setting::audit(setting::PUBLIC_SITE, &setting::who(caller.as_ref()), before, on);
    Json(json!({ "on": on })).into_response()
}

/// Mount the site around the console (call it on the router that already carries
/// `require_auth`): the site's files outermost, then the console's document with its
/// pre-rendered part at a public repository's address ([`shell_layer`]).
pub fn mount<S: Clone + Send + Sync + 'static>(app: Router<S>, s: Arc<site::Site>, state: &AppState) -> Router<S> {
    let shell = Arc::new(Shell::new(s.clone(), state.clone()));
    follow_changes(&shell);
    app.layer(axum::middleware::from_fn_with_state(shell, shell_layer)).layer(axum::middleware::from_fn_with_state(s, site::layer))
}

/// **The console's document, with a page's content in it** (GUN-GUEST, owner 2026-10-30).
pub struct Shell {
    site: Arc<site::Site>,
    state: AppState,
    /// The console's `index.html`, read once (the bundle a process serves does not change
    /// under it: an update restarts the process).
    index: std::sync::OnceLock<Arc<str>>,
}

impl Shell {
    pub fn new(site: Arc<site::Site>, state: AppState) -> Shell {
        Shell { site, state, index: std::sync::OnceLock::new() }
    }

    /// The console's `index.html`, or a bare document where this console has no bundle.
    fn index(&self) -> Arc<str> {
        self.index
            .get_or_init(|| {
                let read = self.state.ui_dir.as_ref().and_then(|d| std::fs::read_to_string(d.join("index.html")).ok());
                Arc::from(read.unwrap_or_else(|| site::shell::BARE_INDEX.to_owned()))
            })
            .clone()
    }

    /// **`repo` changed**: its parts go now, the files follow, and the pages a visitor lands
    /// on (the repository, its commits and its issues) are rendered again at once.
    pub fn changed(self: &Arc<Self>, repo: &str) {
        if !self.site.switch().is_on() {
            return;
        }
        self.site.prerendered().invalidate_repo(repo);
        let Ok(rt) = tokio::runtime::Handle::try_current() else { return };
        let (me, repo) = (self.clone(), repo.to_owned());
        rt.spawn(async move {
            let _ = me.site.changed(&repo).await;
            let Some((ns, name)) = repo.split_once('/') else { return };
            let Some(published) = me.site.publishes(ns, name) else { return };
            for (path, kind) in [(format!("/{repo}"), crate::prerender::Kind::Home), (format!("/{repo}/commits"), crate::prerender::Kind::Commits), (format!("/{repo}/issues"), crate::prerender::Kind::Issues)] {
                if let Ok(part) = me.render(&published, &kind, None).await {
                    me.site.prerendered().put(&path, &repo, part, std::time::Instant::now());
                }
            }
        });
    }

    async fn render(&self, repo: &site::RepoRef, kind: &crate::prerender::Kind, query: Option<&str>) -> Result<site::shell::Part, crate::prerender::Unavailable> {
        let built = self.site.built();
        let kept = built.issues.get(&site::issues::list_path(repo)).map(|(_, l)| l.clone());
        crate::prerender::render(&self.state, self.site.config(), repo, kind, query, kept.as_deref().map(|v| v.as_slice())).await
    }
}

/// The event kinds that change what a repository's pages show.
const CHANGES: &[&str] = &["ref_updated", "ref_deleted", "repo_created", "repo_deleted", "repo_renamed", "visibility_changed", "default_branch_changed", "encrypted_changed"];

/// **Gunnar's pushes, heard**: the console's feed carries gunnar's events; each one that
/// moves a repository's refs (or its visibility) rebuilds that repository's pages at once.
fn follow_changes(shell: &Arc<Shell>) {
    let Ok(rt) = tokio::runtime::Handle::try_current() else { return };
    let (_, mut rx) = shell.state.events.subscribe_with_backlog();
    let weak = Arc::downgrade(shell);
    rt.spawn(async move {
        loop {
            match rx.recv().await {
                Ok(item) => {
                    let Some(shell) = weak.upgrade() else { return };
                    if let Some(repo) = item.repo.filter(|_| CHANGES.contains(&item.kind.as_str())) {
                        shell.changed(&repo);
                    }
                }
                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
                Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
            }
        }
    });
}

/// `ns/name` when `path` is one of the console's issue writes (`POST …/issues`,
/// `…/issues/{n}/comment`, `…/issues/{n}/close`).
fn issue_write(path: &str) -> Option<String> {
    let rest = path.strip_prefix("/api/repos/")?;
    let mut parts = rest.split('/');
    let (ns, name) = (parts.next()?, parts.next()?);
    let tail: Vec<&str> = parts.collect();
    let write = matches!(tail.as_slice(), ["issues"] | ["issues", _, "comment"] | ["issues", _, "close"]);
    write.then(|| format!("{ns}/{name}"))
}

/// The 429 before an on-demand render: no render and no gunnar call.
fn too_many(wait: u64) -> Response {
    let mut r = (StatusCode::TOO_MANY_REQUESTS, "Too many requests. Please wait and try again.\n").into_response();
    r.headers_mut().insert(http::header::RETRY_AFTER, http::HeaderValue::from(wait));
    r
}

/// **May this request render a page now?** The console's one throttle keeps a lower budget for
/// a render (`Throttle::admit_render`), per client and for the whole box; its layer put the
/// throttle and the client's key on the request. A request that did not pass it (none in a
/// served console) renders nothing.
fn admit_render(request: &axum::extract::Request) -> Result<(), u64> {
    let throttle = request.extensions().get::<Arc<crate::throttle::Throttle>>();
    let client = request.extensions().get::<crate::throttle::Client>();
    match (throttle, client) {
        (Some(throttle), Some(client)) => throttle.admit_render(&client.key),
        _ => Err(60),
    }
}

/// **The layer**: switched off, it closes the guest arm (a visitor sees only `/`); on, a
/// public repository's address answers the console's document with the page's content in
/// it, from the cache or rendered now; an issue written through the console rebuilds.
pub async fn shell_layer(axum::extract::State(shell): axum::extract::State<Arc<Shell>>, mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
    use http::Method;
    let site = &shell.site;
    if !site.switch().is_on() {
        request.extensions_mut().insert(crate::auth::middleware::GuestsOff);
        return next.run(request).await;
    }
    if *request.method() == Method::POST {
        let Some(repo) = issue_write(request.uri().path()) else { return next.run(request).await };
        let answer = next.run(request).await;
        if answer.status().is_success() {
            shell.changed(&repo);
        }
        return answer;
    }
    if !matches!(*request.method(), Method::GET | Method::HEAD) {
        return next.run(request).await;
    }
    let Some(path) = site::normalise(request.uri().path()) else { return next.run(request).await };
    // **A namespace's page** (GUN-FIELD-TEST, 2026-11-01): the sitemap names `/{ns}`; its
    // first HTML lists the namespace's public repositories, from the site's own list.
    if let Some(ns) = crate::address::namespace(&path) {
        site.start_on_first_ask();
        let repos = site.namespace_repos(ns);
        if repos.is_empty() {
            return next.run(request).await;
        }
        let config = site.config();
        let (meta, html) = crate::prerender::namespace_page(config, ns, &repos);
        let doc = site::shell::compose(config, &shell.index(), &meta, &html);
        let head = *request.method() == Method::HEAD;
        let mut answer = Response::new(if head { axum::body::Body::empty() } else { axum::body::Body::from(doc) });
        let h = answer.headers_mut();
        h.insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("text/html; charset=utf-8"));
        h.insert(http::header::CACHE_CONTROL, http::HeaderValue::from_static("no-cache"));
        return answer;
    }
    let Some((ns, name, leaf, tail)) = crate::address::repository(&path) else { return next.run(request).await };
    let Some(kind) = crate::prerender::kind_of(leaf, tail) else { return next.run(request).await };
    site.start_on_first_ask();
    let Some(repo) = site.publishes(ns, name) else { return next.run(request).await };
    let query = request.uri().query().map(str::to_owned);
    let searched = kind == crate::prerender::Kind::Issues && site::issues::Filter::of_query(query.as_deref()).is_some();
    let kept = if searched { None } else { site.prerendered().get(&path) };
    let part = match kept {
        Some(part) => part,
        None => {
            if let Err(wait) = admit_render(&request) {
                return too_many(wait);
            }
            match shell.render(&repo, &kind, query.as_deref()).await {
                Ok(part) if searched => Arc::new(part),
                Ok(part) => site.prerendered().put(&path, &repo.path(), part, std::time::Instant::now()),
                Err(why) => {
                    tracing::debug!(%path, why = %why.0, "a page was not pre-rendered; the console is served without it");
                    return next.run(request).await;
                }
            }
        }
    };
    let config = site.config();
    let canonical = if kind == crate::prerender::Kind::Issues { site::issues::list_path(&repo) } else { request.uri().path().trim_end_matches('/').to_owned() };
    let mut meta = site::head::repo_meta(config, &repo, part.title.clone(), canonical);
    meta.description = part.description.clone();
    meta.noindex = searched || part.status != 200;
    let doc = site::shell::compose(config, &shell.index(), &meta, &part.html);
    let head = *request.method() == Method::HEAD;
    let mut answer = Response::new(if head { axum::body::Body::empty() } else { axum::body::Body::from(doc) });
    *answer.status_mut() = StatusCode::from_u16(part.status).unwrap_or(StatusCode::OK);
    let h = answer.headers_mut();
    h.insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("text/html; charset=utf-8"));
    // The same document for everybody (no cloaking), and never kept stale: a push shows at once.
    h.insert(http::header::CACHE_CONTROL, http::HeaderValue::from_static("no-cache"));
    answer
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_blob_is_text_unless_binary_or_an_lfs_pointer() {
        assert_eq!(text_of(b"hello\n", false).as_deref(), Some("hello\n"));
        assert_eq!(text_of(b"a\0b", false), None);
        assert_eq!(text_of(b"version https://git-lfs.github.com/spec/v1\noid sha256:aa\nsize 3\n", false), None);
    }

    #[cfg(feature = "metered")]
    #[test]
    fn the_offer_is_the_shops_words() {
        let body = serde_json::json!({"plans": [
            {"id": "s", "name": "Small", "resources_words": "10 GB", "price_words": "100 kr a month", "period_days": 30},
            {"id": "x", "name": "", "resources_words": "nameless"}
        ], "market": "SE"});
        let o = offer_of(&body).expect("an offer");
        assert_eq!(o.items, vec![site::OfferItem { title: "Small".into(), lines: vec!["10 GB".into(), "100 kr a month".into()] }]);
        assert!(offer_of(&serde_json::json!({"plans": []})).is_none());
        assert!(offer_of(&serde_json::json!({"error": "down"})).is_none());
    }

    #[test]
    fn every_console_route_is_a_reserved_namespace() {
        let mut c = site::Config::gunnar("https://gunnar.example");
        c.reserved.extend(crate::address::RESERVED.iter().filter(|p| !p.is_empty()).map(|p| (*p).to_owned()));
        for ns in ["repos", "repo", "access", "storage", "usage", "events", "account", "mirrors", "overview", "admin", "machine", "cli", "api", "auth", "health", "login", "welcome", "signup", "private"] {
            assert!(c.reserved.iter().any(|r| r == ns), "{ns}");
        }
    }

    /// ★ **The airgap default** (owner 2026-10-25): with the feature on and no
    /// IndexNow key stored or in the environment, the site has no outbound call to make.
    #[test]
    fn without_its_own_setting_the_console_announces_nothing() {
        assert!(std::env::var(site::ENV_INDEXNOW_KEY).is_err(), "the test environment sets an IndexNow key");
        assert!(site::Config::gunnar_from_env("https://gunnar.example").indexnow.is_none());
    }

    /// ★ A32-16: the key in effect fails closed, as a switch does: nothing read is OFF whatever
    /// the environment says, nothing stored is the environment's key, a stored key wins, and an
    /// empty or damaged stored value is OFF.
    #[test]
    fn the_indexnow_key_in_effect_is_the_stored_one_and_fails_closed() {
        const ENV: &str = "0123456789abcdef";
        const STORED: &str = "fedcba9876543210";
        assert_eq!(indexnow_key(Some(ENV), None), None, "nothing read yet: OFF");
        assert_eq!(indexnow_key(Some(ENV), Some(&None)).as_deref(), Some(ENV), "nothing stored: the environment's key");
        assert_eq!(indexnow_key(Some("short"), Some(&None)), None, "an environment value that is no key");
        assert_eq!(indexnow_key(Some(ENV), Some(&Some(STORED.as_bytes().to_vec()))).as_deref(), Some(STORED), "the stored key wins");
        assert_eq!(indexnow_key(Some(ENV), Some(&Some(Vec::new()))), None, "cleared: OFF");
        assert_eq!(indexnow_key(Some(ENV), Some(&Some(b"not a key".to_vec()))), None, "damaged: OFF");
    }

    /// PUB-ISSUES: a thread is its comments and its close; the opening is the issue itself.
    #[test]
    fn an_issue_thread_is_its_comments_and_its_close() {
        use pb::forge_event::Kind;
        let ev = |kind: Kind| pb::ForgeEvent { repo: "a/b".into(), pull: 1, actor: "someone".into(), at_unix_ms: 1_761_300_000_999, kind: Some(kind) };
        assert_eq!(note_of(ev(Kind::IssueOpened(pb::IssueOpened { title: "t".into(), body: "b".into() }))), None);
        assert_eq!(note_of(ev(Kind::IssueCommented(pb::IssueCommented { body: "hi".into() }))), Some(site::IssueNote { at_unix: 1_761_300_000, body: "hi".into(), closed: false }));
        assert_eq!(note_of(ev(Kind::IssueClosed(pb::IssueClosed { reason: "fixed".into() }))), Some(site::IssueNote { at_unix: 1_761_300_000, body: "fixed".into(), closed: true }));
    }

    #[test]
    fn the_readme_names_are_tried_markdown_first() {
        assert_eq!(README_NAMES[0], "README.md");
    }
}