Compare commits

...
3 Commits
Author SHA1 Message Date
Bendik LynghaugandClaude Opus 5 c9faaeb4a6 Serve a content repo's .well-known/ at the site root
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 2m3s
Publish release / publish (push) Successful in 1m51s
Everything a site has to publish for another program to find it lives
under /.well-known/, and portal had no way to put a file there: the
catch-all answered those paths 200 with the app shell, so a client
probing for security.txt or matrix/server got HTML where it expected a
document, and could not tell a missing file from a present one.

The prefix is the one place this is safe to overlay. RFC 8615 reserves
it, and a question's id comes from a file name under questions/, which
cannot begin with a dot - so no file a repo ships can shadow a page. The
path still goes through is_safe_site_path, so it cannot climb out of the
repo either, and a miss is a real 404.

Types: an extensionless file under the prefix is JSON, which is what
matrix/server and nodeinfo are; .mobileconfig is the Apple profile type,
without which iOS downloads a profile instead of offering to install it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 10:30:13 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 1a2c064d3a Release 0.6.20
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 2m11s
Publish release / publish (push) Successful in 1m55s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 12:56:30 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 fe7a868b18 A platform's file may name its architecture
A Mac is offered the macos-arm64 file rather than sent past it to
TestFlight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 12:56:19 +02:00
6 changed files with 176 additions and 37 deletions
+8
View File
@@ -4,6 +4,14 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v<version>` tag; the first. A release is a Cargo.toml bump plus a `v<version>` tag; the
content repos pin the tag they run (see the end of this file). 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.
## 0.6.19 (2026-10-05) ## 0.6.19 (2026-10-05)
- A `kind: url` resource whose JSON is a releases manifest (`latest`, `versions` with per-platform `assets`, `channels` such as a package repo or TestFlight) renders as a download card: the file for the visitor's own platform first, decided in the browser, the rest and the channels folded. Written for redoal's public releases at dl.redoal.com (docs: content-reference, "A releases manifest"). - A `kind: url` resource whose JSON is a releases manifest (`latest`, `versions` with per-platform `assets`, `channels` such as a package repo or TestFlight) renders as a download card: the file for the visitor's own platform first, decided in the browser, the rest and the channels folded. Written for redoal's public releases at dl.redoal.com (docs: content-reference, "A releases manifest").
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]] [[package]]
name = "portal" name = "portal"
version = "0.6.19" version = "0.6.21"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "portal" name = "portal"
version = "0.6.19" version = "0.6.21"
edition = "2021" edition = "2021"
[lib] [lib]
+2 -1
View File
@@ -2226,7 +2226,8 @@ fn ReleasesCard(manifest: serde_json::Value) -> impl IntoView {
let p = platform.get()?; let p = platform.get()?;
let arch = arch_for_pick.clone(); let arch = arch_for_pick.clone();
let ios = ios_for_pick.clone(); let ios = ios_for_pick.clone();
let own = assets_for_pick.iter().find(|(pl, _, _)| pl == p).cloned(); // "macos" is satisfied by a "macos-arm64" file, and so on.
let own = assets_for_pick.iter().find(|(pl, _, _)| pl == p || pl.starts_with(&format!("{p}-"))).cloned();
let pick: Option<AnyView> = match (p, own) { let pick: Option<AnyView> = match (p, own) {
(_, Some((pl, url, _))) => Some(view! { (_, Some((pl, url, _))) => Some(view! {
<p><a class="pill" href=url download>{t(&lang().get(), "for_this_device")} ": " {label(&pl)}</a></p> <p><a class="pill" href=url download>{t(&lang().get(), "for_this_device")} ": " {label(&pl)}</a></p>
+115 -15
View File
@@ -49,15 +49,56 @@ pub struct GiteaRepoQuery {
pub async fn site_asset_handler( pub async fn site_asset_handler(
axum::extract::State(state): axum::extract::State<crate::server::AppState>, axum::extract::State(state): axum::extract::State<crate::server::AppState>,
axum::extract::Path(path): axum::extract::Path<String>, axum::extract::Path(path): axum::extract::Path<String>,
) -> 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<crate::server::AppState>,
axum::extract::Path(path): axum::extract::Path<String>,
) -> 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 { ) -> axum::response::Response {
use axum::http::{header, StatusCode}; use axum::http::{header, StatusCode};
use axum::response::IntoResponse; 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(); return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
} }
let url = format!("{}/{path}?ref={}", state.content_raw_base, state.content_branch); let url = format!(
let resp = match crate::http::client().get(&url).send().await.and_then(|r| r.error_for_status()) { "{}/{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, Ok(r) => r,
Err(e) => { Err(e) => {
tracing::warn!("site asset {path}: {e}"); tracing::warn!("site asset {path}: {e}");
@@ -79,12 +120,26 @@ pub async fn site_asset_handler(
Some("avif") => "image/avif", Some("avif") => "image/avif",
Some("ico") => "image/x-icon", Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2", 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", _ => "application/octet-stream",
}; };
// Photographs are large and do not change under a name, so a // Photographs are large and do not change under a name, so a
// browser may keep them for an hour; everything else revalidates, // browser may keep them for an hour; everything else revalidates,
// so a content push shows on reload. // 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)], [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
bytes, bytes,
@@ -114,14 +169,22 @@ pub async fn gitea_repo_handler(
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}; };
if !is_safe_segment(&query.owner) || !is_safe_segment(&query.repo) { 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 GiteaRepoQuery { owner, repo } = query;
let api_url = format!("{}/api/v1/repos/{owner}/{repo}", state.gitea_base); let api_url = format!("{}/api/v1/repos/{owner}/{repo}", state.gitea_base);
let body = crate::http::get_text(&api_url, crate::http::GITEA_CAP) let body = crate::http::get_text(&api_url, crate::http::GITEA_CAP)
.await .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| { let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
( (
axum::http::StatusCode::BAD_GATEWAY, axum::http::StatusCode::BAD_GATEWAY,
@@ -168,27 +231,38 @@ pub async fn automation_kv_handler(
.and_then(|v| v.strip_prefix("Bearer ")) .and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or(""); .unwrap_or("");
if expected.is_empty() || presented != expected { 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 // The token authenticates the automation service; what it may read
// is the policy's to say, like every other principal. // is the policy's to say, like every other principal.
let decision = crate::access::decide_and_publish( let decision = crate::access::decide_and_publish(
&state.nats, &state.nats,
&state.access.load(), &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::Action::AutomationRead,
&crate::access::Resource::Bucket { name: bucket.clone() }, &crate::access::Resource::Bucket {
name: bucket.clone(),
},
serde_json::json!({}), serde_json::json!({}),
); );
if !decision.allow { 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 let store = state.jetstream.get_key_value(&bucket).await.map_err(|e| {
.jetstream (
.get_key_value(&bucket) axum::http::StatusCode::BAD_GATEWAY,
.await format!("bucket unavailable: {e}"),
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("bucket unavailable: {e}")))?; )
})?;
use futures::TryStreamExt; use futures::TryStreamExt;
let keys: Vec<String> = store let keys: Vec<String> = store
@@ -209,3 +283,29 @@ pub async fn automation_kv_handler(
Ok(axum::Json(serde_json::Value::Array(items))) 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");
}
}
}
+49 -19
View File
@@ -27,7 +27,14 @@ async fn main() -> anyhow::Result<()> {
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
async fn serve() -> anyhow::Result<()> { 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::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes}; use leptos_axum::{generate_route_list, LeptosRoutes};
use portal::app::{shell, App}; use portal::app::{shell, App};
@@ -57,12 +64,15 @@ async fn serve() -> anyhow::Result<()> {
let gitea_base = content::gitea_api_base(&content_repo)?; let gitea_base = content::gitea_api_base(&content_repo)?;
let content_raw_base = content::gitea_raw_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 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)?; content::validate_questions(&questions, &aggregates)?;
let site = content::load_site_from_gitea(&content_repo, &content_branch).await?; let site = content::load_site_from_gitea(&content_repo, &content_branch).await?;
let storylines = content::same_arity(questions.arity(), aggregates.arity(), site.arity())?; 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"); 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 questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions));
let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates)); let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates));
let site = Arc::new(arc_swap::ArcSwap::from_pointee(site)); 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 // The same decisions, answerable by other apps over NATS, for a
// person named by phone number or address as well as by groups. // 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 // Announced pages: keep their desk records current, close ended
// windows (see announce.rs). // windows (see announce.rs).
tokio::spawn(portal::announce::run(state.clone())); tokio::spawn(portal::announce::run(state.clone()));
@@ -202,18 +217,17 @@ async fn serve() -> anyhow::Result<()> {
}); });
match &governor { match &governor {
Some(_) => tracing::info!(rate_per_second, rate_burst, "rate limiting per caller IP"), 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( async fn server_fn_handler(
State(state): State<AppState>, State(state): State<AppState>,
request: Request<Body>, request: Request<Body>,
) -> impl IntoResponse { ) -> impl IntoResponse {
leptos_axum::handle_server_fns_with_context( leptos_axum::handle_server_fns_with_context(move || provide_context(state.clone()), request)
move || provide_context(state.clone()), .await
request,
)
.await
} }
// The app's own Leptos route list includes a root-level wildcard // 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-light.svg", ServeFile::new(favicon_light_path))
.route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path)) .route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path))
.route_service("/wordmark.svg", ServeFile::new(wordmark_path)) .route_service("/wordmark.svg", ServeFile::new(wordmark_path))
.route_service( .route_service("/swiper-element-bundle.min.js", ServeFile::new(swiper_path))
"/swiper-element-bundle.min.js",
ServeFile::new(swiper_path),
)
// Same name every release, so revalidated like /pkg. // Same name every release, so revalidated like /pkg.
.route_service( .route_service(
"/page-motion.js", "/page-motion.js",
@@ -291,7 +302,9 @@ async fn serve() -> anyhow::Result<()> {
// with room for the multipart framing. // with room for the multipart framing.
.route( .route(
"/upload", "/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( .route(
"/attachment/{bucket}/{record}/{field}", "/attachment/{bucket}/{record}/{field}",
@@ -302,7 +315,11 @@ async fn serve() -> anyhow::Result<()> {
.route("/report", get(portal::report::handler)) .route("/report", get(portal::report::handler))
// Content-shipped assets (hero module etc.), same-origin. // Content-shipped assets (hero module etc.), same-origin.
.route("/site/{*path}", get(content::site_asset_handler)) .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 // Maps, proxied: the browser asks this origin for every tile
// and every still, so nothing third-party loads in a page and // and every still, so nothing third-party loads in a page and
// the token stays here (see maps.rs). // the token stays here (see maps.rs).
@@ -324,7 +341,10 @@ async fn serve() -> anyhow::Result<()> {
.fallback(leptos_axum::file_and_error_handler::<AppState, _>(shell)) .fallback(leptos_axum::file_and_error_handler::<AppState, _>(shell))
.layer(session_layer) .layer(session_layer)
// Which storyline this visitor reads, before anything renders. // 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 // Outermost, so a caller who is over the limit is turned away
// before a session is loaded or a handler is entered. // before a session is loaded or a handler is entered.
let app = match governor { 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 raw = std::env::var("SITE_NAME").unwrap_or_else(|_| "portal".to_string());
let folded: String = raw let folded: String = raw
.chars() .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(); .collect();
if folded.is_empty() { "portal".to_string() } else { folded } if folded.is_empty() {
"portal".to_string()
} else {
folded
}
} }