Files
decision/crates/ld-server/src/lib.rs
T

210 lines
8.4 KiB
Rust

//! 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<Config>,
pub http: reqwest::Client,
/// Wakes the publisher as soon as an adoption is recorded.
pub publish_wake: Arc<Notify>,
}
impl AppState {
pub async fn new(cfg: Config, db: SqlitePool) -> anyhow::Result<Self> {
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<SqlitePool> {
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<AppState>) -> 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<AppState> {
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::<std::net::SocketAddr>())
.with_graceful_shutdown(shutdown())
.await?;
Ok(())
}