diff --git a/CHANGELOG.md b/CHANGELOG.md index f5729e3..75c9cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## 0.6.21 (2026-10-07) + +- A content repo's `.well-known/` is served at the site root (`/.well-known/security.txt`, `/.well-known/assetlinks.json`, `/.well-known/matrix/server`), and a miss there is a 404 instead of the app shell answering 200 with an HTML page. RFC 8615 reserves the prefix and a question's id cannot begin with a dot, so nothing a repo ships can shadow a page. An extensionless file under it is typed as JSON, by the convention those files follow; `.txt`, `.xml`, `.webmanifest` and `.mobileconfig` are typed too, the last so an Apple configuration profile offers to install rather than download. + ## 0.6.20 (2026-10-05) - A releases card lets a platform's file carry an architecture: a Mac visitor is offered the `macos-arm64` file, not sent to TestFlight past it. diff --git a/Cargo.lock b/Cargo.lock index 9865d01..2f174f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3357,7 +3357,7 @@ dependencies = [ [[package]] name = "portal" -version = "0.6.20" +version = "0.6.21" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 89c0afd..14c891d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "portal" -version = "0.6.20" +version = "0.6.21" edition = "2021" [lib] diff --git a/src/content/handlers.rs b/src/content/handlers.rs index 732d483..31297f6 100644 --- a/src/content/handlers.rs +++ b/src/content/handlers.rs @@ -49,15 +49,56 @@ pub struct GiteaRepoQuery { pub async fn site_asset_handler( axum::extract::State(state): axum::extract::State, axum::extract::Path(path): axum::extract::Path, +) -> axum::response::Response { + serve_repo_file(&state, &path, false).await +} + +/// `GET /.well-known/{*path}` - the content repo overlaid on the one part +/// of the URL space that can never collide with a question: RFC 8615 +/// reserves `/.well-known/`, and a question's id is derived from a file +/// name under `questions/`, which cannot begin with a dot. A repo ships +/// `.well-known/security.txt`, `.well-known/assetlinks.json`, +/// `.well-known/matrix/server` and so on at the path they are served at, +/// and a miss is a real 404 rather than the app shell answering 200 with +/// an HTML page - which is what a client probing for one of these would +/// otherwise have to parse. +/// +/// Paths Caddy claims in front of portal (mail autoconfiguration, the +/// CalDAV and CardDAV redirects) never reach here. +#[cfg(feature = "ssr")] +pub async fn well_known_handler( + axum::extract::State(state): axum::extract::State, + axum::extract::Path(path): axum::extract::Path, +) -> axum::response::Response { + serve_repo_file(&state, &format!(".well-known/{path}"), true).await +} + +/// Proxies one repo-relative file from the content repo's default branch. +/// `well_known` only changes what an extensionless file is typed as: under +/// `/.well-known/` those are JSON by convention (`matrix/server`, +/// `nodeinfo`), while elsewhere a file with no extension is an unknown. +#[cfg(feature = "ssr")] +async fn serve_repo_file( + state: &crate::server::AppState, + path: &str, + well_known: bool, ) -> axum::response::Response { use axum::http::{header, StatusCode}; use axum::response::IntoResponse; - if !is_safe_site_path(&path) { + if !is_safe_site_path(path) { return (StatusCode::BAD_REQUEST, "invalid asset path").into_response(); } - let url = format!("{}/{path}?ref={}", state.content_raw_base, state.content_branch); - let resp = match crate::http::client().get(&url).send().await.and_then(|r| r.error_for_status()) { + let url = format!( + "{}/{path}?ref={}", + state.content_raw_base, state.content_branch + ); + let resp = match crate::http::client() + .get(&url) + .send() + .await + .and_then(|r| r.error_for_status()) + { Ok(r) => r, Err(e) => { tracing::warn!("site asset {path}: {e}"); @@ -79,12 +120,26 @@ pub async fn site_asset_handler( Some("avif") => "image/avif", Some("ico") => "image/x-icon", Some("woff2") => "font/woff2", + Some("txt") => "text/plain; charset=utf-8", + Some("xml") => "application/xml; charset=utf-8", + Some("webmanifest") => "application/manifest+json", + // An Apple configuration profile only offers to install when it + // arrives under this type; anything else is just a download. + Some("mobileconfig") => "application/x-apple-aspen-config", + // A file with no extension at all: rsplit yields the whole name. + _ if well_known && !path.rsplit('/').next().unwrap_or_default().contains('.') => { + "application/json; charset=utf-8" + } _ => "application/octet-stream", }; // Photographs are large and do not change under a name, so a // browser may keep them for an hour; everything else revalidates, // so a content push shows on reload. - let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") { "public, max-age=3600" } else { "no-cache" }; + let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") { + "public, max-age=3600" + } else { + "no-cache" + }; ( [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)], bytes, @@ -114,14 +169,22 @@ pub async fn gitea_repo_handler( .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') }; if !is_safe_segment(&query.owner) || !is_safe_segment(&query.repo) { - return Err((axum::http::StatusCode::BAD_REQUEST, "invalid owner/repo".to_string())); + return Err(( + axum::http::StatusCode::BAD_REQUEST, + "invalid owner/repo".to_string(), + )); } let GiteaRepoQuery { owner, repo } = query; let api_url = format!("{}/api/v1/repos/{owner}/{repo}", state.gitea_base); let body = crate::http::get_text(&api_url, crate::http::GITEA_CAP) .await - .map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("fetching {api_url}: {e}")))?; + .map_err(|e| { + ( + axum::http::StatusCode::BAD_GATEWAY, + format!("fetching {api_url}: {e}"), + ) + })?; let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { ( axum::http::StatusCode::BAD_GATEWAY, @@ -168,27 +231,38 @@ pub async fn automation_kv_handler( .and_then(|v| v.strip_prefix("Bearer ")) .unwrap_or(""); if expected.is_empty() || presented != expected { - return Err((axum::http::StatusCode::UNAUTHORIZED, "unauthorized".to_string())); + return Err(( + axum::http::StatusCode::UNAUTHORIZED, + "unauthorized".to_string(), + )); } // The token authenticates the automation service; what it may read // is the policy's to say, like every other principal. let decision = crate::access::decide_and_publish( &state.nats, &state.access.load(), - &crate::access::Principal::Service { name: "automation".to_string() }, + &crate::access::Principal::Service { + name: "automation".to_string(), + }, crate::access::Action::AutomationRead, - &crate::access::Resource::Bucket { name: bucket.clone() }, + &crate::access::Resource::Bucket { + name: bucket.clone(), + }, serde_json::json!({}), ); if !decision.allow { - return Err((axum::http::StatusCode::FORBIDDEN, "not authorized".to_string())); + return Err(( + axum::http::StatusCode::FORBIDDEN, + "not authorized".to_string(), + )); } - let store = state - .jetstream - .get_key_value(&bucket) - .await - .map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("bucket unavailable: {e}")))?; + let store = state.jetstream.get_key_value(&bucket).await.map_err(|e| { + ( + axum::http::StatusCode::BAD_GATEWAY, + format!("bucket unavailable: {e}"), + ) + })?; use futures::TryStreamExt; let keys: Vec = store @@ -209,3 +283,29 @@ pub async fn automation_kv_handler( Ok(axum::Json(serde_json::Value::Array(items))) } +#[cfg(test)] +mod tests { + use super::*; + + /// The overlay's whole safety argument: `/.well-known/` is reserved by + /// RFC 8615 and a question's id comes from a file under `questions/`, so + /// a repo file served at the site root cannot shadow a page - and the + /// path it is fetched by still cannot climb out of the repo. + #[test] + fn well_known_paths_stay_inside_the_repo() { + for path in [ + ".well-known/security.txt", + ".well-known/matrix/server", + ".well-known/assetlinks.json", + ] { + assert!(is_safe_site_path(path), "{path} should be servable"); + } + for path in [ + ".well-known/../questions/index.yaml", + ".well-known//escape", + "/.well-known/security.txt", + ] { + assert!(!is_safe_site_path(path), "{path} should be refused"); + } + } +} diff --git a/src/main.rs b/src/main.rs index d86e5f7..39fa89a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,7 +27,14 @@ async fn main() -> anyhow::Result<()> { #[cfg(feature = "ssr")] async fn serve() -> anyhow::Result<()> { - use axum::{body::Body, extract::State, http::Request, response::IntoResponse, routing::{any, get, post}, Router}; + use axum::{ + body::Body, + extract::State, + http::Request, + response::IntoResponse, + routing::{any, get, post}, + Router, + }; use leptos::prelude::*; use leptos_axum::{generate_route_list, LeptosRoutes}; use portal::app::{shell, App}; @@ -57,12 +64,15 @@ async fn serve() -> anyhow::Result<()> { let gitea_base = content::gitea_api_base(&content_repo)?; let content_raw_base = content::gitea_raw_base(&content_repo)?; let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?; - let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?; + let questions = + content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?; content::validate_questions(&questions, &aggregates)?; let site = content::load_site_from_gitea(&content_repo, &content_branch).await?; let storylines = content::same_arity(questions.arity(), aggregates.arity(), site.arity())?; tracing::info!(count = questions.len(), storylines, repo = %content_repo, branch = %content_branch, "loaded content"); - let access = Arc::new(arc_swap::ArcSwap::from_pointee(portal::access::Policy::from_content(&questions, &aggregates))); + let access = Arc::new(arc_swap::ArcSwap::from_pointee( + portal::access::Policy::from_content(&questions, &aggregates), + )); let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions)); let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates)); let site = Arc::new(arc_swap::ArcSwap::from_pointee(site)); @@ -128,7 +138,12 @@ async fn serve() -> anyhow::Result<()> { // The same decisions, answerable by other apps over NATS, for a // person named by phone number or address as well as by groups. - tokio::spawn(portal::access::serve(state.nats.clone(), site_name_for_subjects(), state.access.clone(), Some(state.clone()))); + tokio::spawn(portal::access::serve( + state.nats.clone(), + site_name_for_subjects(), + state.access.clone(), + Some(state.clone()), + )); // Announced pages: keep their desk records current, close ended // windows (see announce.rs). tokio::spawn(portal::announce::run(state.clone())); @@ -202,18 +217,17 @@ async fn serve() -> anyhow::Result<()> { }); match &governor { Some(_) => tracing::info!(rate_per_second, rate_burst, "rate limiting per caller IP"), - None => tracing::warn!("rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)"), + None => tracing::warn!( + "rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)" + ), } async fn server_fn_handler( State(state): State, request: Request, ) -> impl IntoResponse { - leptos_axum::handle_server_fns_with_context( - move || provide_context(state.clone()), - request, - ) - .await + leptos_axum::handle_server_fns_with_context(move || provide_context(state.clone()), request) + .await } // The app's own Leptos route list includes a root-level wildcard @@ -264,10 +278,7 @@ async fn serve() -> anyhow::Result<()> { .route_service("/favicon-light.svg", ServeFile::new(favicon_light_path)) .route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path)) .route_service("/wordmark.svg", ServeFile::new(wordmark_path)) - .route_service( - "/swiper-element-bundle.min.js", - ServeFile::new(swiper_path), - ) + .route_service("/swiper-element-bundle.min.js", ServeFile::new(swiper_path)) // Same name every release, so revalidated like /pkg. .route_service( "/page-motion.js", @@ -291,7 +302,9 @@ async fn serve() -> anyhow::Result<()> { // with room for the multipart framing. .route( "/upload", - post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)), + post(upload::upload).layer(axum::extract::DefaultBodyLimit::max( + upload::MAX_UPLOAD_BYTES + 64 * 1024, + )), ) .route( "/attachment/{bucket}/{record}/{field}", @@ -302,7 +315,11 @@ async fn serve() -> anyhow::Result<()> { .route("/report", get(portal::report::handler)) // Content-shipped assets (hero module etc.), same-origin. .route("/site/{*path}", get(content::site_asset_handler)) - .route("/automation/kv/{bucket}", get(content::automation_kv_handler)) + .route("/.well-known/{*path}", get(content::well_known_handler)) + .route( + "/automation/kv/{bucket}", + get(content::automation_kv_handler), + ) // Maps, proxied: the browser asks this origin for every tile // and every still, so nothing third-party loads in a page and // the token stays here (see maps.rs). @@ -324,7 +341,10 @@ async fn serve() -> anyhow::Result<()> { .fallback(leptos_axum::file_and_error_handler::(shell)) .layer(session_layer) // Which storyline this visitor reads, before anything renders. - .layer(axum::middleware::from_fn_with_state(state.clone(), portal::server::storyline::assign)); + .layer(axum::middleware::from_fn_with_state( + state.clone(), + portal::server::storyline::assign, + )); // Outermost, so a caller who is over the limit is turned away // before a session is loaded or a handler is entered. let app = match governor { @@ -362,7 +382,17 @@ fn site_name_for_subjects() -> String { let raw = std::env::var("SITE_NAME").unwrap_or_else(|_| "portal".to_string()); let folded: String = raw .chars() - .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c.to_ascii_lowercase() } else { '_' }) + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c.to_ascii_lowercase() + } else { + '_' + } + }) .collect(); - if folded.is_empty() { "portal".to_string() } else { folded } + if folded.is_empty() { + "portal".to_string() + } else { + folded + } }