~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
818 lines · 31.4 KB Raw History
//! Repositories' websites, answered on a domain of their own,
//! `<owner>.knotless.page/<repo>/`, from the `dev.knotless.site` records
//! the index keeps: each owner at a label of the domain, their handle
//! written as [`knotless::site::label`] writes it, or their plc id; each
//! file read from its owner's PDS once, checked against its record, and
//! kept by its hash (see docs/design/pages.md).
//!
//! A private repository's site is answered at a host of its own,
//! `<key>-<plc id>.private.<domain>`, to its members alone, from the
//! record this server's index of its space keeps. The domain has no login:
//! a visitor with no pass for the host is sent to the pages, which, once
//! they are logged in there and proven a member, send them back with a
//! ticket, signed here, for that host alone, that lasts a minute and is
//! taken once. The host trades it for a cookie of its own. Membership is
//! asked again as the pages ask it ([`crate::private::REPROVE`]), so one
//! removed stops reading the site as they stop reading the repository.

use crate::{App, AppError};
use axum::body::Body;
use axum::extract::{Query, Request, State};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use knotless::crypto;
use knotless::records::{ReleaseFile, SiteFile, SiteRecord, nsid};
use knotless::release::valid_sha256;
use knotless::remote::Remote;
use knotless::url::RepoUrl;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

/// How long a browser or proxy may use a file before asking again, by
/// its ETag: a site changes, and its paths are not named by their bytes.
const FRESH: &str = "public, max-age=60";

/// How many sites' maps are kept before all are read again.
const MAPS: usize = 1024;

/// What a private site answers with: for its visitor alone, kept by no
/// cache.
const UNSHARED: &str = "private, no-store";

/// Where on the pages a visitor to a private site is given a ticket to it.
pub const TICKETS: &str = "/oauth/site";

/// Where on a private site's host a ticket is traded for its cookie.
pub const ENTER: &str = "/.knotless/enter";

/// The cookie a private site's host keeps its visitor's pass in: under
/// https, `__Host-`, so no other host sets it.
const PASS: &str = "knotless_site";

/// How long a ticket lasts, in seconds.
const TICKET_LASTS: u64 = 60;

/// How long a pass lasts, in seconds.
const PASS_LASTS: u64 = 8 * 60 * 60;

/// How many members proven of private sites are kept before all are
/// asked again.
const MEMBERS: usize = 4096;

/// What a ticket or a pass says: who, to which host, from when.
#[derive(Serialize, Deserialize)]
struct Claims {
    /// `site-ticket` or `site`.
    kind: String,
    did: String,
    host: String,
    /// When it was signed, in microseconds since the epoch: a logout
    /// refuses every one signed before it.
    iat: u64,
    exp: u64,
    jti: String,
}

/// Who was proven a member of which private site: the viewer, the owner
/// and the repository's key.
type Proving = (String, String, String);

/// A member of a private repository, proven: the repository's space, and
/// its owner's repo there, read as them.
struct Member {
    space: String,
    remote: Remote,
}

/// The sites a domain answers for.
pub struct Sites {
    app: Arc<App>,
    /// The domain each owner is a label of, as `knotless.page`.
    domain: String,
    /// Where a site's owner is sent from its domain's root: the pages'
    /// own address, with the owner's name after it.
    home: String,
    /// Each site's files by path, as last read from its record, kept
    /// until the record changes.
    maps: Mutex<HashMap<String, (String, Arc<Map>)>>,
    /// Members proven of private sites, by viewer, owner and key, and
    /// when.
    members: Mutex<HashMap<Proving, (Instant, Arc<Member>)>>,
    /// Tickets taken, by their `jti`, until they expire.
    taken: Mutex<HashMap<String, u64>>,
}

/// A site's record, and its files by path.
pub struct Map {
    pub site: SiteRecord,
    paths: HashMap<String, usize>,
}

impl Map {
    pub fn new(site: SiteRecord) -> Map {
        let paths = site.files.iter().enumerate();
        let paths = paths.map(|(i, f)| (f.path.clone(), i)).collect();
        Map { site, paths }
    }

    fn file(&self, path: &str) -> Option<&SiteFile> {
        self.paths.get(path).map(|&i| &self.site.files[i])
    }
}

/// What a site answers a path under it with.
#[derive(Debug, PartialEq)]
pub enum Found<'a> {
    /// A file, with the status to send it with.
    File(&'a SiteFile, StatusCode),
    /// Where the same page is, with a `/` it lacks.
    Moved(String),
    /// Nothing.
    Missing,
}

/// What `map` answers `rest`, the path under the site, with: the file at
/// it, or at `<rest>index.html` for a directory's; a redirect to the
/// directory for a path that is one without its `/`; for a page that is
/// neither, the site's fallback; and for anything else, its 404 page.
/// `page` says whether the request is for a page, not a script or image.
pub fn find<'a>(map: &'a Map, rest: &str, page: bool) -> Found<'a> {
    let at = match rest.is_empty() || rest.ends_with('/') {
        true => format!("{rest}index.html"),
        false => rest.to_string(),
    };
    if let Some(file) = map.file(&at) {
        return Found::File(file, StatusCode::OK);
    }
    if !rest.is_empty() && map.file(&format!("{rest}/index.html")).is_some() {
        let name = rest.rsplit('/').next().unwrap_or(rest);
        return Found::Moved(format!("{name}/"));
    }
    let fallback = map.site.fallback.as_deref().filter(|_| page);
    if let Some(file) = fallback.and_then(|f| map.file(f)) {
        return Found::File(file, StatusCode::OK);
    }
    let not_found = map.site.not_found.as_deref().and_then(|f| map.file(f));
    match not_found {
        Some(file) => Found::File(file, StatusCode::NOT_FOUND),
        None => Found::Missing,
    }
}

/// Whether a request is for a page: it asks for HTML, or its last
/// segment has no extension, as a single-page app's routes do not.
pub fn is_page(rest: &str, headers: &HeaderMap) -> bool {
    let accept = headers.get(header::ACCEPT).and_then(|v| v.to_str().ok());
    let html = accept.is_some_and(|a| a.contains("text/html"));
    let last = rest.rsplit('/').next().unwrap_or(rest);
    html || !last.contains('.')
}

/// `path` with each `%XX` read as the byte it names: none for one that is
/// not UTF-8 then.
pub fn decode(path: &str) -> Option<String> {
    let bytes = path.as_bytes();
    let (mut out, mut i) = (Vec::with_capacity(bytes.len()), 0);
    while i < bytes.len() {
        let hex = bytes
            .get(i + 1..i + 3)
            .and_then(|h| std::str::from_utf8(h).ok());
        let byte = hex
            .filter(|_| bytes[i] == b'%')
            .and_then(|h| u8::from_str_radix(h, 16).ok());
        match byte {
            Some(b) => (out.push(b), i += 3),
            None => (out.push(bytes[i]), i += 1),
        };
    }
    String::from_utf8(out).ok()
}

/// The owner `label` names: a plc id as itself, or a handle as
/// [`knotless::site::handle`] reads it back.
pub enum Owner {
    Did(String),
    Handle(String),
}

/// The owner a host's label names, if it is one: a host of `domain` one
/// label deep.
pub fn owner(host: &str, domain: &str) -> Option<Owner> {
    let host = host.split(':').next().unwrap_or(host).to_ascii_lowercase();
    let label = host.strip_suffix(domain)?.strip_suffix('.')?;
    if let Some(handle) = knotless::site::handle(label) {
        return Some(Owner::Handle(handle));
    }
    let plc = label.len() == 24
        && label
            .bytes()
            .all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b));
    plc.then(|| Owner::Did(format!("did:plc:{label}")))
}

/// The label of a private site's host of `domain`, and the owner and
/// repository key it names: a host one label under `private.<domain>`.
pub fn private_host(host: &str, domain: &str) -> Option<(String, String, String)> {
    let host = host.split(':').next().unwrap_or(host).to_ascii_lowercase();
    let under = format!(".{}.{domain}", knotless::site::PRIVATE);
    let label = host.strip_suffix(&under)?;
    let (did, key) = knotless::site::private_site(label)?;
    Some((label.to_string(), did, key))
}

/// The host a request was asked of, as a proxy in front says.
fn host(headers: &HeaderMap) -> Option<&str> {
    let named = |n: &str| headers.get(n).and_then(|v| v.to_str().ok());
    named("x-forwarded-host").or(named(header::HOST.as_str()))
}

/// Answers a request asked of `sites`' domain, one label deep, as the site
/// it names, and hands every other on to `next`.
pub async fn dispatch(State(sites): State<Arc<Sites>>, req: Request, next: Next) -> Response {
    let domain = &sites.domain;
    let asked = host(req.headers()).map(|h| h.split(':').next().unwrap_or(h).to_ascii_lowercase());
    let ours = asked
        .as_deref()
        .is_some_and(|h| h == domain || h.ends_with(&format!(".{domain}")));
    if !ours {
        return next.run(req).await;
    }
    if req.method() != Method::GET && req.method() != Method::HEAD {
        let allow = [(header::ALLOW, "GET, HEAD")];
        return (StatusCode::METHOD_NOT_ALLOWED, allow, "only GET and HEAD\n").into_response();
    }
    let private = asked.as_deref().and_then(|h| private_host(h, domain));
    if let Some((label, did, key)) = private {
        return sites.private(&label, &did, &key, req).await;
    }
    let owner = asked.as_deref().and_then(|h| owner(h, domain));
    match owner {
        Some(owner) => sites.answer(owner, req).await,
        None => missing(),
    }
}

/// A plain 404.
fn missing() -> Response {
    (StatusCode::NOT_FOUND, "no such site\n").into_response()
}

/// A redirect to `to`, temporary, as a name can later be another's.
fn moved(to: &str) -> Response {
    let to = HeaderValue::from_str(to).unwrap_or(HeaderValue::from_static("/"));
    (StatusCode::FOUND, [(header::LOCATION, to)]).into_response()
}

/// Files read, numbered, so two reading one at once each write their own.
static KEEPING: AtomicU64 = AtomicU64::new(0);

impl Sites {
    /// The sites `app` indexes, answered as labels of `domain`, their
    /// owners sent from its root to their page at `home`.
    pub fn new(app: Arc<App>, domain: String, home: String) -> Arc<Sites> {
        app.pages_domain.get_or_init(|| domain.clone());
        Arc::new(Sites {
            app,
            domain,
            home,
            maps: Mutex::default(),
            members: Mutex::default(),
            taken: Mutex::default(),
        })
    }

    /// The scheme the pages, and so the sites, are served over.
    fn scheme(&self) -> &str {
        self.home.split_once("://").map_or("https", |(s, _)| s)
    }

    /// The private site at `host`'s cookie's name.
    fn pass_name(&self) -> String {
        let prefix = ["", "__Host-"][usize::from(self.scheme() == "https")];
        format!("{prefix}{PASS}")
    }

    /// A ticket or pass of `kind` for `did` to `host`, lasting `secs`.
    fn sign(&self, kind: &str, did: &str, host: &str, secs: u64) -> anyhow::Result<String> {
        let key = knotless::oauth::client_key(&self.app.ctx)?;
        let now = knotless::keys::now_micros();
        let claims = Claims {
            kind: kind.into(),
            did: did.into(),
            host: host.into(),
            iat: now,
            exp: now / 1_000_000 + secs,
            jti: knotless::oauth::random(16),
        };
        Ok(key.sign_jwt(&json!({ "typ": "JWT" }), &claims))
    }

    /// What `token` says, if it is a `kind` signed here for `host`, not
    /// expired, for an account whose login here holds.
    fn read(&self, token: &str, kind: &str, host: &str) -> Option<Claims> {
        let key = knotless::oauth::client_key(&self.app.ctx).ok()?;
        let claims: Claims = crypto::verify_jwt(token, &key.public()).ok()?;
        let now = knotless::keys::now_micros() / 1_000_000;
        let fits = claims.kind == kind && claims.host == host && claims.exp > now;
        let holds = fits && crate::login::holds(&self.app, &claims.did, claims.iat);
        holds.then_some(claims)
    }

    /// Takes the ticket `claims` says: false for one taken before.
    fn take(&self, claims: &Claims) -> bool {
        let now = knotless::keys::now_micros() / 1_000_000;
        let mut taken = self.taken.lock().unwrap();
        taken.retain(|_, exp| *exp > now);
        taken.insert(claims.jti.clone(), claims.exp).is_none()
    }

    /// Where the pages give a ticket to `host`, to come back to `to`.
    fn to_tickets(&self, host: &str, to: &str) -> Response {
        let home = self.home.trim_end_matches('/');
        let query = format!("host={}&to={}", encode(host), encode(to));
        unshared(moved(&format!("{home}{TICKETS}?{query}")))
    }

    /// `viewer`, proven a member of `did`'s private repository with
    /// `key`, as the pages last proved them, or now: none if they are not
    /// one, or this server keeps no index of the repository's space.
    async fn member(&self, viewer: &str, did: &str, key: &str) -> Option<Arc<Member>> {
        let at = (viewer.to_string(), did.to_string(), key.to_string());
        let reprove = self.app.reprove.load(Ordering::Relaxed);
        let reprove = Duration::from_millis(reprove);
        let kept = self.members.lock().unwrap().get(&at).cloned();
        if let Some((_, member)) = kept.filter(|(at, _)| at.elapsed() < reprove) {
            return Some(member);
        }
        let proven = self.prove(viewer, did, key).await;
        let mut members = self.members.lock().unwrap();
        // Kept for as many as read at once, and asked again past.
        let room = members.len() < MEMBERS;
        members.retain(|_, _| room);
        match &proven {
            Some(member) => members.insert(at, (Instant::now(), member.clone())),
            None => members.remove(&at),
        };
        proven
    }

    /// [`Sites::member`], asked of the owner's PDS: a credential to the
    /// space, which it gives only to a member; and the space followed
    /// here, synced as them, so its site's record is in the index.
    async fn prove(&self, viewer: &str, did: &str, key: &str) -> Option<Arc<Member>> {
        let app = &self.app;
        let index = app.spaces.as_ref()?;
        let name = index.private_repo(did, key).ok().flatten();
        let name = name.filter(|_| !app.taken_down(did))?;
        let space = knotless::space::uri(did, &name);
        let url = RepoUrl {
            authority: did.to_string(),
            name,
        };
        let ctx = crate::login::ctx_for(app, viewer);
        knotless::space::forget(&ctx);
        let remote = Remote::open(&ctx, &url).await.ok()?;
        remote.space.as_ref()?;
        app.keep(&space, viewer).await.ok()?;
        Some(Arc::new(Member { space, remote }))
    }

    /// Answers a request asked of the private site at `label`, `did`'s
    /// repository with `key`: to its members, with a pass for the host;
    /// anyone else is sent for a ticket.
    async fn private(&self, label: &str, did: &str, key: &str, req: Request) -> Response {
        let host = format!("{label}.{}.{}", knotless::site::PRIVATE, self.domain);
        let path = req.uri().path().to_string();
        if path == ENTER {
            let entered = self.enter(&host, req.uri().query().unwrap_or_default());
            return entered.unwrap_or_else(IntoResponse::into_response);
        }
        let to = req.uri().path_and_query().map_or("/", |p| p.as_str());
        let pass = crate::login::cookie(req.headers(), &self.pass_name());
        let pass = pass.and_then(|p| self.read(&p, "site", &host));
        let Some(pass) = pass else {
            return self.to_tickets(&host, to);
        };
        let Some(member) = self.member(&pass.did, did, key).await else {
            return unshared(missing());
        };
        let Some(path) = decode(&path) else {
            return unshared(missing());
        };
        let rest = path.trim_start_matches('/');
        let index = self.app.spaces.as_ref();
        let text = index.and_then(|i| i.space_site(&member.space, did, key).ok().flatten());
        let uri = format!("{}/{did}/{}/{key}", member.space, nsid::SITE);
        let Some(map) = text.and_then(|text| self.kept(&uri, text)) else {
            return unshared(missing());
        };
        let page = is_page(rest, req.headers());
        let from = From::Space(&member.remote, label);
        let sent = match find(&map, rest, page) {
            Found::File(file, status) => self.send(from, file, status, req.headers()).await,
            Found::Moved(to) => moved(&to),
            Found::Missing => missing(),
        };
        unshared(sent)
    }

    /// Trades the ticket `query` holds for a pass to `host`, and goes on
    /// to the page it names; a ticket that does not hold is sent for
    /// another.
    fn enter(&self, host: &str, query: &str) -> Result<Response, AppError> {
        let asked: HashMap<String, String> = query_pairs(query);
        let to = crate::login::back_to(asked.get("to").map(String::as_str));
        let ticket = asked.get("ticket");
        let claims = ticket.and_then(|t| self.read(t, "site-ticket", host));
        let Some(claims) = claims.filter(|c| self.take(c)) else {
            return Ok(self.to_tickets(host, &to));
        };
        let pass = self.sign("site", &claims.did, host, PASS_LASTS);
        let pass = pass.map_err(AppError::internal)?;
        let secure = ["", "; Secure"][usize::from(self.scheme() == "https")];
        let name = self.pass_name();
        let cookie =
            format!("{name}={pass}; Path=/; Max-Age={PASS_LASTS}; HttpOnly; SameSite=Lax{secure}");
        let mut res = unshared(moved(&to));
        if let Ok(cookie) = HeaderValue::from_str(&cookie) {
            res.headers_mut().append(header::SET_COOKIE, cookie);
        }
        Ok(res)
    }

    /// The DID `owner` is: a handle's only if its DID claims it back.
    async fn did(&self, owner: &Owner) -> Option<String> {
        let ctx = &self.app.ctx;
        let handle = match owner {
            Owner::Did(did) => return Some(did.clone()),
            Owner::Handle(handle) => handle,
        };
        let did = knotless::identity::resolve_handle(ctx, handle).await.ok()?;
        let claimed = knotless::identity::handle(ctx, &did).await;
        (claimed.as_deref() == Some(handle.as_str())).then_some(did)
    }

    /// `did`'s site of the repository named `name`, as its record was
    /// last indexed.
    fn map(&self, did: &str, name: &str) -> Option<(String, Arc<Map>)> {
        let index = self.app.index.as_ref()?;
        let repo = index.repo(&format!("at://{did}/{}/{name}", nsid::REPO));
        let key = repo.ok()??["key"].as_str()?.to_string();
        let repo_uri = format!("at://{did}/{}/{name}", nsid::REPO);
        let down = index.down(&[&repo_uri]).unwrap_or(true);
        let text = index.site(did, &key).ok()?.filter(|_| !down)?;
        let uri = format!("at://{did}/{}/{key}", nsid::SITE);
        Some((key, self.kept(&uri, text)?))
    }

    /// The map of the site whose record is `uri`, `text` as last indexed:
    /// the one kept, if it was made of the same.
    fn kept(&self, uri: &str, text: String) -> Option<Arc<Map>> {
        let mut maps = self.maps.lock().unwrap();
        // Kept for as many sites as are read at once, and read again past.
        let room = maps.len() < MAPS;
        maps.retain(|_, _| room);
        if let Some((kept, map)) = maps.get(uri)
            && *kept == text
        {
            return Some(map.clone());
        }
        let site = serde_json::from_str::<SiteRecord>(&text).ok()?;
        let map = Arc::new(Map::new(site));
        maps.insert(uri.to_string(), (text, map.clone()));
        Some(map)
    }

    /// Gives a ticket to the private site `asked` names, to come back to
    /// the page it names there, to one logged in and proven a member;
    /// sends one not logged in to log in, and back here after.
    async fn ticket(&self, asked: Tickets, headers: &HeaderMap) -> Result<Response, AppError> {
        let to = crate::login::back_to(asked.to.as_deref());
        let Some((label, did, key)) = private_host(&asked.host, &self.domain) else {
            return Ok(unshared(missing()));
        };
        let Some(viewer) = crate::login::viewer(&self.app, headers) else {
            let here = format!("{TICKETS}?host={}&to={}", encode(&asked.host), encode(&to));
            return Ok(unshared(moved(&format!("/?login={}", encode(&here)))));
        };
        if self.member(&viewer, &did, &key).await.is_none() {
            return Ok(unshared(missing()));
        }
        let host = format!("{label}.{}.{}", knotless::site::PRIVATE, self.domain);
        let ticket = self.sign("site-ticket", &viewer, &host, TICKET_LASTS);
        let ticket = ticket.map_err(AppError::internal)?;
        let query = format!("ticket={ticket}&to={}", encode(&to));
        Ok(unshared(moved(&format!(
            "{}://{host}{ENTER}?{query}",
            self.scheme()
        ))))
    }

    async fn answer(&self, owner: Owner, req: Request) -> Response {
        let Some(did) = self.did(&owner).await else {
            return missing();
        };
        let Some(path) = decode(req.uri().path()) else {
            return missing();
        };
        let path = path.trim_start_matches('/');
        let (name, rest) = match path.split_once('/') {
            Some((name, rest)) => (name, Some(rest)),
            None => (path, None),
        };
        if name.is_empty() {
            let named = match &owner {
                Owner::Handle(handle) => handle,
                Owner::Did(did) => did,
            };
            return moved(&format!("{}/{named}", self.home.trim_end_matches('/')));
        }
        // A browser reads `/\x` as `//x`, another host: no repository's
        // name holds a `\` or a control character, so none is redirected to.
        if name.contains('\\') || name.chars().any(char::is_control) {
            return missing();
        }
        let Some(rest) = rest else {
            return moved(&format!("/{name}/"));
        };
        let Some((_, map)) = self.map(&did, name) else {
            return missing();
        };
        let page = is_page(rest, req.headers());
        match find(&map, rest, page) {
            Found::File(file, status) => {
                let from = From::Public(&did, name);
                self.send(from, file, status, req.headers()).await
            }
            Found::Moved(to) => moved(&to),
            Found::Missing => missing(),
        }
    }

    /// `file` of the site `from` names, with `status`: 304 to one who has
    /// it by its ETag already.
    async fn send(
        &self,
        from: From<'_>,
        file: &SiteFile,
        status: StatusCode,
        asked: &HeaderMap,
    ) -> Response {
        let etag = format!("\"{}\"", file.sha256);
        let kind = file.content_type.as_deref();
        let kind = kind.or(knotless::site::content_type(&file.path));
        let headers = [
            (
                header::CONTENT_TYPE,
                kind.unwrap_or("application/octet-stream").to_string(),
            ),
            (header::ETAG, etag.clone()),
            (header::CACHE_CONTROL, FRESH.to_string()),
            (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
        ];
        let has = asked
            .get(header::IF_NONE_MATCH)
            .and_then(|v| v.to_str().ok());
        if status == StatusCode::OK && has == Some(etag.as_str()) {
            return (StatusCode::NOT_MODIFIED, headers).into_response();
        }
        let opened = match self.fetch(from, file).await {
            Ok(opened) => opened,
            Err(e) => return (StatusCode::BAD_GATEWAY, format!("{e:#}\n")).into_response(),
        };
        let body = Body::from_stream(tokio_util::io::ReaderStream::new(opened));
        let length = [(header::CONTENT_LENGTH, file.size.to_string())];
        (status, length, headers, body).into_response()
    }

    /// `file`, opened where it is kept, by its hash, as release files
    /// are: read first, if it is not yet, from the owner's PDS, and
    /// checked. A private site's are kept apart, each site's by itself, so
    /// that no other site's record, naming the same hash, is served them.
    async fn fetch(&self, from: From<'_>, file: &SiteFile) -> anyhow::Result<tokio::fs::File> {
        let ctx = &self.app.ctx;
        let hash = &file.sha256;
        anyhow::ensure!(valid_sha256(hash), "{hash:?} is not a SHA-256");
        let dir = crate::releases::kept_dir(ctx);
        let dir = match &from {
            From::Public(..) => dir,
            From::Space(_, label) => dir.join("private").join(label),
        };
        let at = dir.join(hash);
        if !tokio::fs::try_exists(&at).await? {
            self.keep(from, file, &dir, &at).await?;
        }
        Ok(tokio::fs::File::open(at).await?)
    }

    /// Reads `file` from the PDS of the site `from` names into `at`, in
    /// `dir`, checked.
    async fn keep(
        &self,
        from: From<'_>,
        file: &SiteFile,
        dir: &std::path::Path,
        at: &std::path::Path,
    ) -> anyhow::Result<()> {
        let ctx = &self.app.ctx;
        let remote = match from {
            From::Public(did, name) => {
                let url = RepoUrl {
                    authority: did.to_string(),
                    name: name.to_string(),
                };
                Remote::open(ctx, &url).await?
            }
            From::Space(remote, _) => remote.clone(),
        };
        tokio::fs::create_dir_all(dir).await?;
        let n = KEEPING.fetch_add(1, Ordering::Relaxed);
        let keeping = dir.join(format!(".{}.{n}", file.sha256));
        let read = ReleaseFile {
            name: file.path.clone(),
            size: file.size,
            sha256: file.sha256.clone(),
            parts: file.parts.clone(),
        };
        knotless::release::read_into(&remote, &read, &keeping).await?;
        tokio::fs::rename(&keeping, at).await?;
        Ok(())
    }
}

/// Where a site's files are read from: a public one's owner and
/// repository name, or a private one's repo in its space, read as its
/// member, and its host's label.
enum From<'a> {
    Public(&'a str, &'a str),
    Space(&'a Remote, &'a str),
}

/// What the pages are asked for a ticket with.
#[derive(Deserialize)]
pub struct Tickets {
    host: String,
    to: Option<String>,
}

/// [`Sites::ticket`], as a route of the pages.
async fn tickets(
    State(sites): State<Arc<Sites>>,
    headers: HeaderMap,
    Query(asked): Query<Tickets>,
) -> Result<Response, AppError> {
    sites.ticket(asked, &headers).await
}

/// `res`, for its visitor alone, and kept by no cache.
fn unshared(mut res: Response) -> Response {
    let headers = res.headers_mut();
    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static(UNSHARED));
    res
}

/// `text` with every byte but the unreserved ones written `%XX`, for a
/// query.
pub fn encode(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    for b in text.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
                out.push(b as char)
            }
            b => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}

/// A query's pairs, each `%XX` and `+` read back.
fn query_pairs(query: &str) -> HashMap<String, String> {
    let pairs = query.split('&').filter_map(|pair| pair.split_once('='));
    let read = |s: &str| decode(&s.replace('+', " "));
    pairs
        .filter_map(|(k, v)| Some((read(k)?, read(v)?)))
        .collect()
}

/// `routes`, with every request asked of `sites`' domain answered as a
/// site instead, and the pages giving tickets to private sites.
pub fn hosted(routes: axum::Router, sites: Arc<Sites>) -> axum::Router {
    let tickets = axum::Router::new()
        .route(TICKETS, axum::routing::get(tickets))
        .with_state(sites.clone());
    routes
        .merge(tickets)
        .layer(axum::middleware::from_fn_with_state(sites, dispatch))
}

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

    fn file(path: &str) -> SiteFile {
        SiteFile {
            path: path.into(),
            size: 0,
            sha256: String::new(),
            content_type: None,
            parts: Vec::new(),
        }
    }

    fn map(paths: &[&str], fallback: Option<&str>, not_found: Option<&str>) -> Map {
        Map::new(SiteRecord {
            files: paths.iter().map(|p| file(p)).collect(),
            not_found: not_found.map(String::from),
            fallback: fallback.map(String::from),
            commit: None,
            check: None,
            created_at: String::new(),
        })
    }

    fn path<'a>(found: &Found<'a>) -> Option<(&'a str, StatusCode)> {
        match found {
            Found::File(f, status) => Some((f.path.as_str(), *status)),
            _ => None,
        }
    }

    #[test]
    fn paths_are_answered() {
        let plain = map(&["index.html", "a.css", "docs/index.html"], None, None);
        assert_eq!(
            path(&find(&plain, "", true)).unwrap(),
            ("index.html", StatusCode::OK)
        );
        assert_eq!(
            path(&find(&plain, "a.css", false)).unwrap(),
            ("a.css", StatusCode::OK)
        );
        assert_eq!(
            path(&find(&plain, "docs/", true)).unwrap(),
            ("docs/index.html", StatusCode::OK)
        );
        assert_eq!(find(&plain, "docs", true), Found::Moved("docs/".into()));
        assert_eq!(find(&plain, "nope", true), Found::Missing);
        assert_eq!(path(&find(&plain, "nope", true)), None);

        let app = map(
            &["index.html", "404.html"],
            Some("index.html"),
            Some("404.html"),
        );
        assert_eq!(
            path(&find(&app, "settings/me", true)).unwrap(),
            ("index.html", StatusCode::OK)
        );
        // A script that is missing is not the app's page.
        let gone = find(&app, "app.js", false);
        assert_eq!(path(&gone).unwrap(), ("404.html", StatusCode::NOT_FOUND));
        // A fallback that names no file falls to the 404 page.
        let odd = map(&["404.html"], Some("gone.html"), Some("404.html"));
        assert_eq!(
            path(&find(&odd, "x", true)).unwrap(),
            ("404.html", StatusCode::NOT_FOUND)
        );
    }

    #[test]
    fn pages_hosts_and_paths() {
        let mut headers = HeaderMap::new();
        assert!(is_page("settings", &headers));
        assert!(!is_page("app.js", &headers));
        headers.insert(header::ACCEPT, HeaderValue::from_static("text/html,*/*"));
        assert!(is_page("about.php", &headers));

        assert_eq!(decode("a%20b/%E2%9C%93").as_deref(), Some("a b/✓"));
        assert_eq!(decode("100%").as_deref(), Some("100%"));
        assert_eq!(decode("%zz").as_deref(), Some("%zz"));
        assert_eq!(decode("%ff"), None);

        let handle = |h: &str| match owner(h, "knotless.page") {
            Some(Owner::Handle(h)) => Some(h),
            _ => None,
        };
        assert_eq!(
            handle("Alice-Test.knotless.page:443").as_deref(),
            Some("alice.test")
        );
        let plc = "abcdefghijklmnopqrstuvwx";
        let did = owner(&format!("{plc}.knotless.page"), "knotless.page");
        assert!(matches!(did, Some(Owner::Did(d)) if d == format!("did:plc:{plc}")));
        for none in [
            "knotless.page",
            "a.b-c.knotless.page",
            "short.knotless.page",
            "x-knotless.page",
        ] {
            assert!(handle(none).is_none(), "{none}");
        }
    }
}