v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
//! 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 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?;
|
||||
sqlx::migrate!("./migrations").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/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/identities/{id}/admin", post(access::set_admin))
|
||||
.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 {
|
||||
Router::new()
|
||||
.nest("/api", api_router())
|
||||
.fallback_service(web::service(&state.cfg.web_dir))
|
||||
.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()),
|
||||
)
|
||||
.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?;
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user