//! mtgc-server — keeps each user's library so their devices can sync it. //! //! The server is deliberately dumb about libraries: it stores one versioned //! document per user and accepts a write only when the writer names the //! version it is replacing. All merging happens on the devices (see //! `mtgc_core::sync::remote`), which is what lets them keep working offline and //! keeps this side small enough to trust. //! //! Sign-in always happens in the browser, on pages this server serves, and //! ends by handing the app a long-lived token for that one device. Passwords //! therefore never pass through the app, and the app needs no knowledge of //! which sign-in methods a server offers. pub mod auth; pub mod config; pub mod db; mod library; mod pages; use std::sync::{Arc, Mutex, MutexGuard}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, post}; use axum::{Json, Router}; use base64::Engine; use rusqlite::Connection; use sha2::{Digest, Sha256}; pub use config::Config; /// Largest library document accepted. A large collection serialises to a few /// hundred kilobytes, so this leaves an order of magnitude of headroom while /// still bounding what one request can make the server hold. pub const MAX_BODY_BYTES: usize = 16 * 1024 * 1024; #[derive(Clone)] pub struct AppState(Arc); pub struct Inner { pub config: Config, db: Mutex, pub(crate) local: auth::local::LocalAuth, pub(crate) oidc: Option, } impl AppState { pub fn new(config: Config, conn: Connection) -> anyhow::Result { let oidc = config.oidc.clone().map(auth::oidc::Oidc::new).transpose()?; Ok(Self(Arc::new(Inner { config, db: Mutex::new(conn), local: auth::local::LocalAuth::new(), oidc, }))) } pub fn config(&self) -> &Config { &self.0.config } /// The database. Never hold this across an `.await`. pub fn db(&self) -> MutexGuard<'_, Connection> { // A panic while holding the lock leaves nothing half-done that SQLite's // own transactions don't already roll back, so carry on. self.0.db.lock().unwrap_or_else(|e| e.into_inner()) } } pub fn router(state: AppState) -> Router { Router::new() .route("/healthz", get(|| async { "ok" })) .route("/api/info", get(info)) .route("/api/library", get(library::get_library).put(library::put_library)) .route("/api/me", get(auth::me)) .route("/api/devices/{id}", delete(auth::revoke_device)) .route("/auth/start", get(auth::start)) .route("/auth/password", post(auth::local::submit)) .route("/auth/oidc", get(auth::oidc::begin)) .route("/auth/callback", get(auth::oidc::callback)) .route("/auth/token", post(auth::token)) .route("/auth/logout", post(auth::logout)) .layer(axum::extract::DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(state) } /// Unauthenticated, so the app can check that an address really is an mtgc /// server before sending the user's browser there. async fn info(axum::extract::State(state): axum::extract::State) -> Json { let c = state.config(); Json(serde_json::json!({ "server": "mtgc", "api": 1, "local_auth": c.local_auth, "oidc": c.oidc.as_ref().map(|o| o.display_name.clone()), })) } /// An error answered as `{"error": "..."}` with a status code. #[derive(Debug)] pub struct ApiError(pub StatusCode, pub String); impl ApiError { pub fn new(status: StatusCode, msg: impl Into) -> Self { Self(status, msg.into()) } pub fn unauthorized() -> Self { Self::new(StatusCode::UNAUTHORIZED, "not signed in") } } impl IntoResponse for ApiError { fn into_response(self) -> Response { (self.0, Json(serde_json::json!({ "error": self.1 }))).into_response() } } impl From for ApiError { fn from(e: rusqlite::Error) -> Self { tracing::error!("database: {e}"); Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error") } } pub(crate) fn now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } fn random_bytes() -> [u8; N] { let mut buf = [0u8; N]; getrandom::fill(&mut buf).expect("the OS random number generator failed"); buf } pub(crate) fn random_hex(bytes: usize) -> String { let buf: [u8; 32] = random_bytes(); hex(&buf[..bytes.min(32)]) } /// A 256-bit secret, URL-safe. pub(crate) fn random_token() -> String { base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random_bytes::<32>()) } /// Tokens and codes are stored only as hashes, so a leaked database can't be /// used to impersonate a device. SHA-256 rather than a password hash because /// they are already 256 random bits — there is nothing to brute-force. pub(crate) fn sha256_hex(s: &str) -> String { hex(&Sha256::digest(s.as_bytes())) } fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() }