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