//! Repositories' websites, answered on a domain of their own, //! `.knotless.page//`, 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, //! `-.private.`, 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, /// 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)>>, /// Members proven of private sites, by viewer, owner and key, and /// when. members: Mutex)>>, /// Tickets taken, by their `jti`, until they expire. taken: Mutex>, } /// A site's record, and its files by path. pub struct Map { pub site: SiteRecord, paths: HashMap, } 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 `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 { 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 { 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.`. 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>, 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, domain: String, home: String) -> Arc { 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 { 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 { 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> { 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> { 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 { let asked: HashMap = 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 { 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)> { 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> { 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::(&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 { 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 { 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, } /// [`Sites::ticket`], as a route of the pages. async fn tickets( State(sites): State>, headers: HeaderMap, Query(asked): Query, ) -> Result { 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 { 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) -> 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}"); } } }