//! libreDecision server. //! //! One binary serves the JSON API under `/api`, the compiled Svelte interface, //! and runs two background workers: the electorate refresher (size of the Ğ1 //! web of trust) and the publisher (adopted texts pushed to the git repository //! that client applications read). pub mod access; pub mod adoption; pub mod auth; pub mod config; pub mod docs; pub mod error; pub mod proposals; pub mod publish; pub mod seed; pub mod team; pub mod util; pub mod web; pub mod wot; use axum::Router; use axum::extract::{Request, State}; use axum::http::{Method, StatusCode}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, post, put}; use sqlx::SqlitePool; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; use tokio::sync::Notify; pub use config::Config; #[derive(Clone)] pub struct AppState { pub db: SqlitePool, pub cfg: Arc, pub http: reqwest::Client, /// Wakes the publisher as soon as an adoption is recorded. pub publish_wake: Arc, } impl AppState { pub async fn new(cfg: Config, db: SqlitePool) -> anyhow::Result { let http = reqwest::Client::builder() .timeout(Duration::from_secs(15)) .user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION"))) .build()?; Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()) }) } } /// Open (or create) the database and apply migrations. pub async fn open_db(url: &str) -> anyhow::Result { let options = SqliteConnectOptions::from_str(url)? .create_if_missing(true) .journal_mode(SqliteJournalMode::Wal) .foreign_keys(true) .busy_timeout(Duration::from_secs(5)); let max = if url.contains(":memory:") { 1 } else { 8 }; let pool = SqlitePoolOptions::new().max_connections(max).connect_with(options).await?; // A previous image (rollback) must still start on a database a newer one has // migrated: migrations are additive, the unknown ones are tolerated. let mut migrator = sqlx::migrate!("./migrations"); migrator.set_ignore_missing(true); migrator.run(&pool).await?; Ok(pool) } /// Mutating API calls must carry this header: a cross-site form cannot set it, /// and no CORS is ever granted, so it closes CSRF together with SameSite=Lax. pub const CSRF_HEADER: &str = "x-requested-with"; pub const CSRF_VALUE: &str = "libredecision"; async fn csrf_guard(req: Request, next: Next) -> Response { let safe = matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS); let has_header = req.headers().get(CSRF_HEADER).and_then(|v| v.to_str().ok()) == Some(CSRF_VALUE); if safe || has_header { next.run(req).await } else { (StatusCode::FORBIDDEN, axum::Json(serde_json::json!({ "error": "csrf" }))).into_response() } } async fn health(State(state): State) -> impl IntoResponse { let db_ok = sqlx::query_scalar::<_, i64>("SELECT 1").fetch_one(&state.db).await.is_ok(); axum::Json(serde_json::json!({ "ok": db_ok, "build": state.cfg.build })) } pub fn api_router() -> Router { Router::new() .route("/health", get(health)) .route("/auth/config", get(auth::config)) .route("/auth/g1/start", get(auth::g1_start)) .route("/auth/g1/callback", get(auth::g1_callback)) .route("/auth/invitation", post(auth::accept_invitation)) .route("/auth/team", post(team::join)) .route("/auth/team/peek", post(team::peek)) .route("/auth/dev", post(auth::dev_login)) .route("/auth/logout", post(auth::logout)) .route("/me", get(auth::me)) .route("/groups", get(docs::groups)) .route("/groups/{slug}", get(docs::group)) .route("/docs/{id}", get(docs::document)) .route("/docs/{id}/preview", get(docs::preview)) .route("/docs/{id}/assist", post(proposals::assist)) .route("/docs/{id}/proposals", post(proposals::create)) .route("/proposals/{id}", get(proposals::detail).patch(proposals::edit)) .route("/proposals/{id}/submit", post(proposals::submit)) .route("/proposals/{id}/withdraw", post(proposals::withdraw)) .route("/proposals/{id}/split", post(proposals::split)) .route("/proposals/{id}/vote", post(proposals::vote)) .route("/proposals/{id}/signals", post(proposals::signal)) .route("/proposals/{id}/translations/{lang}", put(proposals::translate)) .route("/signals/{id}/lift", post(proposals::lift_signal)) .route("/units/{id}/inertia", post(proposals::propose_inertia)) .route("/feedback", post(access::send_feedback)) .route("/access", get(access::overview)) .route("/access/groups/{slug}/members", post(access::grant)) .route("/access/groups/{slug}/members/{identity}", delete(access::revoke)) .route("/access/invitations", post(access::invite)) .route("/access/invitations/{hash}", delete(access::cancel_invitation)) .route("/access/publication/retry", post(access::publish_now)) .route("/access/team-links", post(team::create)) .route("/access/team-links/{hash}", delete(team::revoke)) .route("/access/identities/{id}/admin", post(access::set_admin)) .route("/access/identities/{id}/sessions", delete(access::close_sessions)) .route("/access/feedback", get(access::feedback_list)) .route("/access/feedback/{id}", post(access::feedback_status)) .layer(middleware::from_fn(csrf_guard)) } pub fn app(state: AppState) -> Router { use axum::http::{HeaderName, HeaderValue, header}; use tower_http::set_header::SetResponseHeaderLayer; // Neighbouring sites under *.librodrome.org are same-site: none may frame us. let security: [(HeaderName, &'static str); 4] = [ (header::X_FRAME_OPTIONS, "DENY"), (header::CONTENT_SECURITY_POLICY, "frame-ancestors 'none'"), (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), (header::REFERRER_POLICY, "same-origin"), ]; let mut router = Router::new().nest("/api", api_router()).fallback_service(web::service(&state.cfg.web_dir)); for (name, value) in security { router = router.layer(SetResponseHeaderLayer::if_not_present(name, HeaderValue::from_static(value))); } router .layer(tower_http::limit::RequestBodyLimitLayer::new(256 * 1024)) .layer(tower_http::compression::CompressionLayer::new()) .layer(tower_http::trace::TraceLayer::new_for_http()) .with_state(state) } /// Ctrl-C, or SIGTERM from `docker stop`. async fn shutdown() { let ctrl_c = async { let _ = tokio::signal::ctrl_c().await; }; #[cfg(unix)] let term = async { match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { Ok(mut s) => { s.recv().await; } Err(_) => std::future::pending::<()>().await, } }; #[cfg(not(unix))] let term = std::future::pending::<()>(); tokio::select! { () = ctrl_c => {} () = term => {} } } pub async fn run() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info,sqlx=warn,tower_http=warn".into()), ) .with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout())) .init(); let cfg = Config::from_env(); tokio::fs::create_dir_all(&cfg.data_dir).await?; let db_url = format!("sqlite://{}", cfg.data_dir.join("libredecision.db").display()); let db = open_db(&db_url).await?; let state = AppState::new(cfg, db).await?; seed::ensure(&state).await?; auth::bootstrap(&state).await?; if let Err(e) = publish::ensure_key(&state).await { tracing::warn!(error = %e, "publication key"); } publish::resync(&state).await?; tokio::spawn(wot::refresher(state.clone())); tokio::spawn(publish::worker(state.clone())); tokio::spawn(adoption::sweeper(state.clone())); let listener = tokio::net::TcpListener::bind(&state.cfg.bind).await?; tracing::info!(bind = %state.cfg.bind, build = %state.cfg.build, "libreDecision listening"); axum::serve(listener, app(state).into_make_service_with_connect_info::()) .with_graceful_shutdown(shutdown()) .await?; Ok(()) }