A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727use axum::extract::{DefaultBodyLimit, State};use axum::http::{Method, header};use axum::response::{IntoResponse, Redirect, Response};use axum::routing::{get, post};use axum::{Json, Router};use base64::Engine;use bytes::Bytes;use http_body_util::Full;use std::convert::Infallible;use tower_http::services::ServeDir;use tower_http::trace::TraceLayer;
use crate::AppState;use crate::admin;use crate::auth::XrpcClaims;use crate::domain_middleware::resolve_domain;use crate::error::AppError;use crate::profile;use crate::rate_limit::CheckResult;use crate::repo;use crate::xrpc;
pub fn router(state: AppState) -> Router { let static_dir = state.config.static_dir.clone();
// SPA fallback: when ServeDir can't find a static file, check if the // parent path contains a _/index.html (Next.js dynamic route shell) // before falling back to the root index.html. let fallback_dir = static_dir.clone(); let spa_fallback = tower::service_fn(move |req: axum::http::Request<_>| { let dir = fallback_dir.clone(); async move { let path = req.uri().path(); let segments: Vec<&str> = path.trim_matches('/').split('/').collect();
// Try _/index.html in the parent directory (matches Next.js dynamic routes) if segments.len() >= 2 { let parent = segments[..segments.len() - 1].join("/"); let dynamic_path = format!("{}/{}/_/index.html", dir, parent); if let Ok(body) = tokio::fs::read(&dynamic_path).await { return Ok::<_, Infallible>( axum::http::Response::builder() .header("content-type", "text/html; charset=utf-8") .body(Full::new(Bytes::from(body))) .unwrap(), ); } }
// Default: serve root index.html let index = format!("{}/index.html", dir); let body = tokio::fs::read(&index).await.unwrap_or_default(); Ok::<_, Infallible>( axum::http::Response::builder() .header("content-type", "text/html; charset=utf-8") .body(Full::new(Bytes::from(body))) .unwrap(), ) } });
let serve_dir = ServeDir::new(&static_dir).not_found_service(spa_fallback);
let domain_routes = Router::new() .merge( crate::spaces::routes::space_routes().layer(axum::middleware::from_fn_with_state( state.clone(), crate::feature_middleware::require_spaces, )), ) .merge(crate::spaces::simplespace::simplespace_routes().layer( axum::middleware::from_fn_with_state( state.clone(), crate::feature_middleware::require_spaces, ), )) .nest("/auth", crate::auth::routes::routes()) .route( "/auth/linked-repo/start", get(crate::linked_repos::flow::start_handler), ) .route( "/auth/linked-repo/info", get(crate::linked_repos::flow::invite_info_handler), ) .nest("/external-auth", crate::external_auth::routes()) .nest("/oauth", crate::oauth::routes::routes()) // https://atproto.com/specs/oauth#types-of-clients .route("/oauth-client-metadata.json", get(client_metadata)) .route("/.well-known/did.json", get(well_known_did_json)) .nest("/api/setup", crate::setup::routes()) .route("/xrpc/app.bsky.actor.getProfile", get(get_profile)) .route( "/xrpc/com.atproto.repo.uploadBlob", post(repo::upload_blob).layer(DefaultBodyLimit::max(50 * 1024 * 1024)), ) .route( "/xrpc/dev.happyview.listApiClients", get(crate::dev_happyview::list_api_clients), ) .route( "/xrpc/dev.happyview.getApiClient", get(crate::dev_happyview::get_api_client), ) .route( "/xrpc/dev.happyview.createApiClient", post(crate::dev_happyview::create_api_client), ) .route( "/xrpc/dev.happyview.deleteApiClient", post(crate::dev_happyview::delete_api_client), ) // Delegation .route( "/xrpc/dev.happyview.delegation.linkAccount", post(crate::delegation::link_account::link_account), ) .route( "/xrpc/dev.happyview.delegation.unlinkAccount", post(crate::delegation::unlink_account::unlink_account), ) .route( "/xrpc/dev.happyview.delegation.addDelegate", post(crate::delegation::add_delegate::add_delegate), ) .route( "/xrpc/dev.happyview.delegation.removeDelegate", post(crate::delegation::remove_delegate::remove_delegate), ) .route( "/xrpc/dev.happyview.delegation.listAccounts", get(crate::delegation::list_accounts::list_accounts), ) .route( "/xrpc/dev.happyview.delegation.getAccount", get(crate::delegation::get_account::get_account), ) .route( "/xrpc/dev.happyview.delegation.listDelegates", get(crate::delegation::list_delegates::list_delegates), ) // Catch-all for dynamically registered lexicons .route("/xrpc/{method}", get(xrpc::xrpc_get).post(xrpc::xrpc_post)) .route("/config", get(config_endpoint)) .route("/settings/logo", get(crate::admin::settings::serve_logo)) .layer(axum::middleware::from_fn_with_state( state.clone(), resolve_domain, ));
let app_routes = Router::new() .nest("/admin", admin::admin_routes(state.clone())) .merge(domain_routes) .route("/", get(|| async { Redirect::to("/dashboard/") })) .fallback_service(serve_dir);
let outer = if let Some(ref base_path) = state.config.base_path { let bp = base_path.clone(); let rewrite_redirects = axum::middleware::from_fn(move |req, next: axum::middleware::Next| { let bp = bp.clone(); async move { let mut response: Response = next.run(req).await; if response.status().is_redirection() && let Some(loc) = response.headers().get(header::LOCATION) && let Ok(loc_str) = loc.to_str() && loc_str.starts_with('/') && !(loc_str.starts_with(&bp) && (loc_str.len() == bp.len() || loc_str.as_bytes().get(bp.len()) == Some(&b'/'))) && let Ok(new_loc) = format!("{}{}", bp, loc_str).parse() { response.headers_mut().insert(header::LOCATION, new_loc); } response } }); Router::new() .route("/health", get(health)) .nest(base_path, app_routes.layer(rewrite_redirects)) } else { Router::new() .route("/health", get(health)) .merge(app_routes) };
outer .layer(TraceLayer::new_for_http()) .layer(axum::middleware::from_fn_with_state(state.clone(), cors)) .layer(axum::middleware::from_fn_with_state( state.clone(), crate::telemetry_middleware::count_http_bytes, )) .with_state(state)}
/// Allowed request methods, shared by both CORS policies.const CORS_ALLOW_METHODS: &str = "GET, POST, DELETE, OPTIONS";
/// Headers a credentialed (first-party, cookie-bearing) request may send.const CORS_ALLOW_HEADERS_CREDENTIALED: &str = "content-type, authorization, cookie, x-client-key, x-client-secret, dpop, \ atproto-accept-labelers, atproto-proxy";
/// Headers a credential-less (third-party, cookieless DPoP) request may send./// Identical to the credentialed set minus `cookie`.const CORS_ALLOW_HEADERS_ANON: &str = "content-type, authorization, x-client-key, x-client-secret, dpop, \ atproto-accept-labelers, atproto-proxy";
/// Cross-Origin Resource Sharing policy.////// This deliberately replaces a single permissive `CorsLayer`. The old policy/// reflected *any* `Origin` **and** allowed credentials, which let a malicious/// page drive the admin API with the victim's cookie and read the response/// (finding C2). Instead we apply two policies keyed on trust:////// - **Trusted first-party origins** — those in the [`DomainCache`] (the domains/// HappyView actually serves the dashboard/admin UI on) — get their origin/// reflected *with* `Access-Control-Allow-Credentials: true`, so the/// cookie-authenticated dashboard works cross-origin if ever hosted on a/// second registered domain./// - **Any other origin** — e.g. a third-party app or an attacker page — gets a/// credential-*less* grant: its origin is reflected but credentials are never/// allowed. Third-party clients authenticate with explicit DPoP + client-key/// headers (never ambient cookies), so they keep working; an attacker page/// can neither ride the admin cookie nor read a credentialed response.////// The one rule that must never be violated: reflecting an arbitrary origin and/// allowing credentials at the same time.async fn cors( State(state): State<AppState>, req: axum::extract::Request, next: axum::middleware::Next,) -> Response { let origin = req .headers() .get(header::ORIGIN) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string());
// No `Origin` header → not a CORS request (same-origin navigation, // server-to-server, curl). Emit no CORS headers at all. let Some(origin) = origin else { return next.run(req).await; };
let credentialed = state.domain_cache.is_allowed_origin(&origin).await;
let is_preflight = req.method() == Method::OPTIONS && req .headers() .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
let mut cors_headers = header::HeaderMap::new(); if let Ok(value) = header::HeaderValue::from_str(&origin) { cors_headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, value); } else { // Malformed origin — refuse it entirely rather than emit a broken header. if is_preflight { return preflight_response(cors_headers); } return next.run(req).await; } cors_headers.insert(header::VARY, header::HeaderValue::from_static("origin")); if credentialed { cors_headers.insert( header::ACCESS_CONTROL_ALLOW_CREDENTIALS, header::HeaderValue::from_static("true"), ); }
if is_preflight { cors_headers.insert( header::ACCESS_CONTROL_ALLOW_METHODS, header::HeaderValue::from_static(CORS_ALLOW_METHODS), ); cors_headers.insert( header::ACCESS_CONTROL_ALLOW_HEADERS, header::HeaderValue::from_static(if credentialed { CORS_ALLOW_HEADERS_CREDENTIALED } else { CORS_ALLOW_HEADERS_ANON }), ); cors_headers.insert( header::ACCESS_CONTROL_MAX_AGE, header::HeaderValue::from_static("86400"), ); return preflight_response(cors_headers); }
let mut resp = next.run(req).await; resp.headers_mut().extend(cors_headers); resp}
/// Build a `204 No Content` preflight response carrying the given CORS headers.fn preflight_response(cors_headers: header::HeaderMap) -> Response { let mut resp = Response::new(axum::body::Body::empty()); *resp.status_mut() = axum::http::StatusCode::NO_CONTENT; resp.headers_mut().extend(cors_headers); resp}
async fn health() -> &'static str { "ok"}
async fn config_endpoint( State(state): State<AppState>, req: axum::extract::Request,) -> Json<serde_json::Value> { let domain_url = crate::domain_middleware::extract_domain(&req) .map(|d| state.config.url_with_base_path(&d.url)) .unwrap_or_else(|| state.config.effective_public_url());
let pool = &state.db; let backend = state.db_backend;
let app_name = crate::admin::settings::get_setting(pool, "app_name", backend) .await .or_else(|| state.config.app_name.clone());
let has_logo_data = crate::admin::settings::get_setting(pool, "logo_data", backend) .await .is_some(); let logo_url = if has_logo_data { Some(format!( "{}/settings/logo", domain_url.trim_end_matches('/') )) } else { crate::admin::settings::get_setting(pool, "logo_uri", backend) .await .or_else(|| state.config.logo_uri.clone()) };
let version: &str = match option_env!("HAPPYVIEW_VERSION") { Some(v) if !v.is_empty() => v.trim_start_matches('v'), _ => env!("CARGO_PKG_VERSION"), };
let spaces_enabled = crate::feature_flags::is_enabled( pool, crate::feature_flags::FeatureFlag::SPACES_ENABLED, backend, ) .await;
Json(serde_json::json!({ "public_url": domain_url, "version": version, "database_backend": format!("{:?}", state.config.database_backend).to_lowercase(), "jetstream_url": state.config.jetstream_url, "relay_url": state.config.relay_url, "plc_url": state.config.plc_url, "default_rate_limit_capacity": state.config.default_rate_limit_capacity, "default_rate_limit_refill_rate": state.config.default_rate_limit_refill_rate, "app_name": app_name, "logo_url": logo_url, "features": { "spaces": spaces_enabled, }, // Startup configuration problems (e.g. an insecure SESSION_SECRET) so the // dashboard can surface them to an operator. Empty when healthy. "configErrors": state.config.config_errors(), }))}
async fn client_metadata( State(state): State<AppState>, req: axum::extract::Request,) -> Json<serde_json::Value> { let raw_domain_url = crate::domain_middleware::extract_domain(&req) .map(|d| d.url.clone()) .unwrap_or_else(|| state.config.public_url.clone()); let domain_url = state.config.url_with_base_path(&raw_domain_url);
let oauth_client = state.oauth.get_for_domain(&raw_domain_url); let mut metadata = serde_json::to_value(&oauth_client.client_metadata).unwrap_or_default();
// The `client_id` field in the response must exactly match the URL the // authorization server fetched. let client_id = format!( "{}/oauth-client-metadata.json", domain_url.trim_end_matches('/') ); metadata["client_id"] = serde_json::Value::String(client_id);
let pool = &state.db; let backend = state.db_backend;
if let Some(name) = crate::admin::settings::get_setting(pool, "app_name", backend).await { metadata["client_name"] = serde_json::Value::String(name); }
if let Some(uri) = crate::admin::settings::get_setting(pool, "client_uri", backend).await { metadata["client_uri"] = serde_json::Value::String(uri); }
// Logo: prefer uploaded logo_data (served at /settings/logo), fall back to logo_uri setting let has_logo_data = crate::admin::settings::get_setting(pool, "logo_data", backend) .await .is_some(); if has_logo_data { metadata["logo_uri"] = serde_json::Value::String(format!( "{}/settings/logo", domain_url.trim_end_matches('/') )); } else if let Some(uri) = crate::admin::settings::get_setting(pool, "logo_uri", backend).await { metadata["logo_uri"] = serde_json::Value::String(uri); }
if let Some(uri) = crate::admin::settings::get_setting(pool, "tos_uri", backend).await { metadata["tos_uri"] = serde_json::Value::String(uri); }
if let Some(uri) = crate::admin::settings::get_setting(pool, "policy_uri", backend).await { metadata["policy_uri"] = serde_json::Value::String(uri); }
let base_scopes = crate::linked_repos::scope::client_base_scopes( oauth_client.client_metadata.scope.as_deref(), &oauth_client.client_metadata.client_id, ); match crate::linked_repos::db::all_scopes(&state).await { Ok(grant_scopes) => { let union = crate::linked_repos::scope::union(&grant_scopes, &base_scopes); metadata["scope"] = serde_json::Value::String(union); } Err(e) => { // Serving a doc without grant scopes would break every linked-repo // authorization; log loudly rather than failing silently. tracing::error!(error = %e, "failed to compute linked-repo scope union"); } }
if metadata.get("jwks").is_some() { match crate::oauth::client_keys::load_keys( pool, backend, state.config.token_encryption_key.as_ref(), crate::oauth::client_keys::INSTANCE_OWNER, ) .await { Ok(keys) => apply_jwks_override(&mut metadata, &keys), Err(e) => { tracing::error!(error = %e, "failed to load instance client keys for jwks"); } } }
Json(metadata)}
fn published_jwks(keys: &[crate::oauth::client_keys::ClientKey]) -> Option<serde_json::Value> { let publishable: Vec<_> = keys .iter() .filter(|k| k.status != crate::oauth::client_keys::KeyStatus::Revoked) .cloned() .collect(); if publishable.is_empty() { return None; } Some(crate::oauth::client_keys::render_jwks(&publishable))}
fn apply_jwks_override( metadata: &mut serde_json::Value, keys: &[crate::oauth::client_keys::ClientKey],) { if metadata.get("jwks").is_none() { return; } if let Some(jwks) = published_jwks(keys) { metadata["jwks"] = jwks; }}
fn extract_public_key_multibase( identity: &crate::service_identity::ServiceIdentity, state: &AppState,) -> Result<String, AppError> { let enc_b64 = identity .signing_key_enc .as_ref() .ok_or_else(|| AppError::Internal("no signing key configured".into()))?;
let encrypted = base64::engine::general_purpose::STANDARD .decode(enc_b64) .map_err(|e| AppError::Internal(format!("invalid signing key encoding: {e}")))?;
let encryption_key = state .config .token_encryption_key .as_ref() .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?;
let private_bytes = crate::plugin::encryption::decrypt(encryption_key, &encrypted) .map_err(|e| AppError::Internal(format!("failed to decrypt signing key: {e}")))?;
let signing_key = p256::ecdsa::SigningKey::from_slice(private_bytes.as_slice()) .map_err(|e| AppError::Internal(format!("invalid signing key: {e}")))?; let public_key = signing_key.verifying_key(); let compressed = public_key.to_sec1_point(true);
// Multikey format: multicodec varint prefix for P-256 (0x1200) then base58btc with 'z' prefix let mut multikey_bytes = vec![0x80, 0x24]; multikey_bytes.extend_from_slice(compressed.as_bytes()); let encoded = multibase::encode(multibase::Base::Base58Btc, &multikey_bytes); Ok(encoded)}
async fn well_known_did_json( State(state): State<AppState>, headers: axum::http::HeaderMap,) -> Result<Json<serde_json::Value>, AppError> { let identity = crate::service_identity::get_identity(&state.db, state.db_backend).await?; let identity = identity.ok_or_else(|| AppError::NotFound("no service identity configured".into()))?;
if identity.mode != crate::service_identity::IdentityMode::DidWeb { return Err(AppError::NotFound( "DID document only served in did:web mode".into(), )); }
let host = headers .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .ok_or_else(|| AppError::BadRequest("missing Host header".into()))?;
let entries = crate::service_entries::list_entries(&state.db, state.db_backend).await?; let entry_pairs: Vec<(String, String)> = entries .iter() .map(|e| (e.fragment_id.clone(), e.service_type.clone())) .collect();
let extra_vms = crate::verification_methods::list_methods(&state.db, state.db_backend) .await? .into_iter() .map(|m| (m.fragment_id, m.key_type, m.public_key_multibase)) .collect::<Vec<_>>();
let service_endpoint = format!("https://{host}");
let signing_key_multibase = extract_public_key_multibase(&identity, &state)?;
let doc = crate::service_identity::generate_did_document( &identity, host, &signing_key_multibase, &entry_pairs, &service_endpoint, &extra_vms, ) .ok_or_else(|| AppError::NotFound("DID document not available".into()))?;
Ok(Json(doc))}
async fn get_profile( State(state): State<AppState>, xrpc_claims: XrpcClaims,) -> Result<Response, AppError> { let claims = xrpc_claims .identity .ok_or_else(|| AppError::Auth("getProfile requires DPoP authentication".into()))?; let check = if let Some(client_key) = claims.client_key() { let cost = state .rate_limiter .default_cost_for_type(client_key, "query"); Some(state.rate_limiter.check(client_key, cost)) } else { None };
if let Some(CheckResult::Limited { retry_after, limit, reset, }) = check { return Err(AppError::RateLimited { retry_after, limit, reset, }); }
let profile = profile::resolve_profile(&state.http, &state.config.plc_url, claims.did()).await?; let mut response = Json(profile).into_response();
if let Some(CheckResult::Allowed { remaining, limit, reset, }) = check { let h = response.headers_mut(); h.insert("RateLimit-Limit", limit.into()); h.insert("RateLimit-Remaining", remaining.into()); h.insert("RateLimit-Reset", reset.into()); }
Ok(response)}
#[cfg(test)]mod tests { use super::{apply_jwks_override, published_jwks}; use crate::oauth::client_keys::{KeyStatus, generate_client_key};
const PRIVATE_JWK_PARAMS: &[&str] = &["d", "p", "q", "dp", "dq", "qi"];
fn key_with_status(status: KeyStatus) -> crate::oauth::client_keys::ClientKey { let mut key = generate_client_key(crate::oauth::client_keys::INSTANCE_OWNER).unwrap(); key.status = status; key }
/// Regression test for the defect: after a rotation there are two /// non-revoked keys (current + retiring), and both must be published — /// the retiring one is what live sessions' refreshes are pinned to. #[test] fn both_current_and_retiring_keys_are_published() { let current = key_with_status(KeyStatus::Current); let retiring = key_with_status(KeyStatus::Retiring);
let jwks = published_jwks(&[current.clone(), retiring.clone()]).unwrap(); let kids: Vec<&str> = jwks["keys"] .as_array() .unwrap() .iter() .map(|k| k["kid"].as_str().unwrap()) .collect();
assert!(kids.contains(¤t.kid.as_str())); assert!(kids.contains(&retiring.kid.as_str())); assert_eq!(kids.len(), 2); }
#[test] fn a_revoked_key_is_not_published_alongside_a_current_one() { let current = key_with_status(KeyStatus::Current); let revoked = key_with_status(KeyStatus::Revoked);
let jwks = published_jwks(&[current.clone(), revoked.clone()]).unwrap(); let kids: Vec<&str> = jwks["keys"] .as_array() .unwrap() .iter() .map(|k| k["kid"].as_str().unwrap()) .collect();
assert_eq!(kids, vec![current.kid.as_str()]); }
#[test] fn published_jwks_contains_no_private_key_material() { let current = key_with_status(KeyStatus::Current); let retiring = key_with_status(KeyStatus::Retiring);
// Sanity check the fixture actually has private material to omit, // so this test cannot pass vacuously. assert!(current.private_jwk["d"].is_string());
let jwks = published_jwks(&[current, retiring]).unwrap(); for key in jwks["keys"].as_array().unwrap() { for param in PRIVATE_JWK_PARAMS { assert!( key.get(*param).is_none(), "published JWK unexpectedly contains private parameter `{param}`" ); } } }
#[test] fn only_revoked_keys_publish_nothing() { let revoked = key_with_status(KeyStatus::Revoked); assert!(published_jwks(&[revoked]).is_none()); }
#[test] fn no_keys_publishes_nothing() { assert!(published_jwks(&[]).is_none()); }
#[test] fn a_public_clients_metadata_gets_no_jwks_field() { let mut metadata = serde_json::json!({ "client_id": "https://example.com/oauth-client-metadata.json", "client_name": "Example", }); let keys = vec![key_with_status(KeyStatus::Current)];
apply_jwks_override(&mut metadata, &keys);
assert!(metadata.get("jwks").is_none()); }
#[test] fn a_confidential_clients_jwks_field_is_replaced_with_the_full_key_set() { let mut metadata = serde_json::json!({ "client_id": "https://example.com/oauth-client-metadata.json", // Stand-in for atrium's single-key projection, which is exactly // what this override must widen. "jwks": {"keys": []}, }); let current = key_with_status(KeyStatus::Current); let retiring = key_with_status(KeyStatus::Retiring);
apply_jwks_override(&mut metadata, &[current.clone(), retiring.clone()]);
let kids: Vec<&str> = metadata["jwks"]["keys"] .as_array() .unwrap() .iter() .map(|k| k["kid"].as_str().unwrap()) .collect(); assert!(kids.contains(¤t.kid.as_str())); assert!(kids.contains(&retiring.kid.as_str())); }}