A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185use axum::extract::{DefaultBodyLimit, State};use axum::http::HeaderMap;use axum::http::{Method, header};use axum::response::{IntoResponse, Response};use axum::routing::{get, post};use axum::{Json, Router};use bytes::Bytes;use http_body_util::Full;use std::convert::Infallible;use std::net::IpAddr;use tower_http::cors::CorsLayer;use tower_http::services::ServeDir;use tower_http::trace::TraceLayer;
use crate::AppState;use crate::admin;use crate::auth::Claims;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);
Router::new() .route("/health", get(health)) .route("/settings/logo", get(crate::admin::settings::serve_logo)) .nest("/admin", admin::admin_routes(state.clone())) .nest("/auth", crate::auth::routes::routes()) .nest("/external-auth", crate::external_auth::routes()) .route("/oauth/client-metadata.json", get(client_metadata)) .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)), ) // Catch-all for dynamically registered lexicons .route("/xrpc/{method}", get(xrpc::xrpc_get).post(xrpc::xrpc_post)) .route("/config", get(config_endpoint)) .fallback_service(serve_dir) .layer(TraceLayer::new_for_http()) .layer( CorsLayer::new() .allow_origin(tower_http::cors::AllowOrigin::mirror_request()) .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, header::COOKIE]) .allow_credentials(true), ) .with_state(state)}
async fn health() -> &'static str { "ok"}
async fn config_endpoint(State(state): State<AppState>) -> Json<serde_json::Value> { Json(serde_json::json!({ "public_url": state.config.public_url }))}
async fn client_metadata(State(state): State<AppState>) -> Json<serde_json::Value> { let mut metadata = serde_json::to_value(&state.oauth.client_metadata).unwrap_or_default();
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); }
// 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", state.config.public_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); }
Json(metadata)}
fn ip_from_forwarded_for(value: Option<&str>) -> Option<IpAddr> { let forwarded = value?; let first = forwarded.split(',').next()?; first.trim().parse::<IpAddr>().ok()}
async fn get_profile( State(state): State<AppState>, claims: Claims, headers: HeaderMap,) -> Result<Response, AppError> { let client_ip = ip_from_forwarded_for(headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())); let rate_key = claims.did().to_string(); let check = state.rate_limiter.check( &rate_key, state.rate_limiter.default_cost_for_type("query"), client_ip, );
if let 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 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)}