johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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}");
}
}
}