563bb99
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::net::SocketAddr;
use std::sync::OnceLock;
use admin::AdminService;
use anyhow::Context;
use axum::Router;
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header};
use axum::response::IntoResponse;
use config::scoped::{AdminhostConfig, ScopedConfig};
use session::{SessionLayer, store};
use tokio::net::TcpListener;
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
use tracing::{Level, info, warn};
const MAX_REQUEST_BODY_BYTES: usize = 2 * 1024 * 1024;
const CACHE_IMMUTABLE: &str = "public, max-age=31536000, immutable";
const CACHE_NO_CACHE: &str = "no-cache";
const CACHE_REVALIDATE: &str = "public, max-age=3600, must-revalidate";
fn build_cors_layer(config: &config::admin::AdminConfig) -> tower_http::cors::CorsLayer {
use axum::http::HeaderName;
use tower_http::cors::{AllowOrigin, CorsLayer};
if config.cors_allow_origins.is_empty() {
return CorsLayer::new();
}
let origins: Vec<HeaderValue> = config
.cors_allow_origins
.iter()
.filter_map(|origin| origin.parse().ok())
.collect();
CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
.allow_credentials(config.cors_allow_credentials())
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([
header::CONTENT_TYPE,
header::AUTHORIZATION,
HeaderName::from_static("x-request-id"),
])
}
fn admin_session_config(cfg: &AdminhostConfig) -> config::session::SessionConfig {
let mut session = cfg.session.clone();
session.cookie_name = Some(cfg.admin.session_cookie_name().to_string());
session.table_name = Some(cfg.admin.session_table_name().to_string());
session
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
export::telemetry::init_tracing();
let cfg = AdminhostConfig::load().context("loading configuration")?;
if !cfg.admin.enabled() {
info!("adminhost is disabled by config; exiting");
return Ok(());
}
info!("connecting to database / redis / storage / email");
let state = export::AppState::with_config(cfg.clone().into_app_config())
.await
.context("building AppState")?;
info!("dependencies ready");
let health = export::health::router_with(Some(state.database.clone()), state.redis.clone());
let session_store = store::connect(state.redis.clone(), &admin_session_config(&cfg))
.await
.context("connecting admin session store")?;
let service = AdminService::new(state);
match service.seed_admin_if_empty().await {
Ok(Some(admin_id)) => info!(%admin_id, "initial admin account seeded"),
Ok(None) => {}
Err(error) => warn!(%error, "admin seed failed"),
}
let api = admin::routes::router(service.clone()).with_state(service);
let doc = admin::openapi::api_doc();
let router: Router = Router::new()
.nest("/api/v1", api)
.merge(health)
.merge(utoipa_swagger_ui::SwaggerUi::new("/api/docs").url("/api/docs/openapi.json", doc))
.fallback(spa_fallback)
.layer(build_cors_layer(&cfg.admin))
.layer(SessionLayer::new(session_store))
.layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_BYTES))
.layer(
TraceLayer::new_for_http()
.make_span_with(
DefaultMakeSpan::new()
.level(Level::INFO)
.include_headers(false),
)
.on_response(DefaultOnResponse::new().level(Level::INFO)),
)
.layer(export::middleware::AppTraceLayer::new());
let bind_addr = cfg.admin.bind_addr().to_string();
let port = cfg.admin.port();
let addr: SocketAddr = format!("{bind_addr}:{port}")
.parse()
.with_context(|| format!("parsing bind address {bind_addr}:{port}"))?;
let listener = TcpListener::bind(addr)
.await
.with_context(|| format!("binding {addr}"))?;
info!(%addr, "adminhost listening");
axum::serve(listener, router.into_make_service())
.with_graceful_shutdown(export::telemetry::shutdown_signal())
.await
.context("axum server error")?;
info!("adminhost shut down");
Ok(())
}
async fn spa_fallback(method: Method, uri: Uri, headers: HeaderMap) -> axum::response::Response {
if !matches!(method, Method::GET | Method::HEAD) {
return StatusCode::METHOD_NOT_ALLOWED.into_response();
}
let path = uri.path();
if path.starts_with("/api") {
return StatusCode::NOT_FOUND.into_response();
}
let asset = match admin_web::file(path) {
Some(asset) => asset,
None => {
if path.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
return StatusCode::NOT_FOUND.into_response();
}
admin_web::index()
}
};
serve_asset(asset, &headers)
}
fn serve_asset(asset: admin_web::Asset, headers: &HeaderMap) -> axum::response::Response {
let etag = etags()
.get(asset.path)
.cloned()
.unwrap_or_else(|| etag_of(asset.content));
let cache_control = HeaderValue::from_static(cache_control(asset.path));
if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH)
&& etag_matches(if_none_match, &etag)
{
return (
StatusCode::NOT_MODIFIED,
[(header::CACHE_CONTROL, cache_control), (header::ETAG, etag)],
)
.into_response();
}
(
[
(header::CONTENT_TYPE, HeaderValue::from_static(asset.mime)),
(header::CACHE_CONTROL, cache_control),
(header::ETAG, etag),
],
asset.content,
)
.into_response()
}
fn etags() -> &'static HashMap<&'static str, HeaderValue> {
static ETAGS: OnceLock<HashMap<&'static str, HeaderValue>> = OnceLock::new();
ETAGS.get_or_init(|| {
let mut map = HashMap::new();
let index = admin_web::index();
map.insert(index.path, etag_of(index.content));
for path in admin_web::paths() {
if let Some(asset) = admin_web::file(path) {
map.insert(asset.path, etag_of(asset.content));
}
}
map
})
}
fn etag_of(content: &[u8]) -> HeaderValue {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
content.hash(&mut hasher);
hasher.write_u64(content.len() as u64);
HeaderValue::from_maybe_shared(format!("W/\"{:016x}\"", hasher.finish()).into_bytes())
.expect("valid etag header")
}
fn cache_control(path: &str) -> &'static str {
if path.starts_with("assets/") {
CACHE_IMMUTABLE
} else if path == "index.html" {
CACHE_NO_CACHE
} else {
CACHE_REVALIDATE
}
}
fn etag_matches(if_none_match: &HeaderValue, etag: &HeaderValue) -> bool {
let Ok(value) = if_none_match.to_str() else {
return false;
};
let Ok(target) = etag.to_str() else {
return false;
};
fn strong(value: &str) -> &str {
value.strip_prefix("W/").unwrap_or(value)
}
value
.split(',')
.map(str::trim)
.any(|candidate| candidate == "*" || strong(candidate) == strong(target))
}