Files
mtgc/crates/mtgc-server/src/lib.rs

159 lines
5.1 KiB
Rust
Raw Normal View History

Replace folder sync with an HTTP sync server, keeping the app offline-first Folder sync left too much to the user's sync tool: platform file access (the Android SAF plugin), choosing the document, merging conflict copies — and its merge base could fall out of step with the document (absorbing conflict copies, "forget", picking a new Android folder), reading every difference as a deletion. The new mtgc-server crate stores one versioned library per user and accepts a write only when it names the version it replaces. It never merges: devices still do that with merge3, through a new sync cycle over a `Remote` trait in mtgc-core. The base advances only on knowledge — to what was read, or to a write the server confirmed — and is stored with the server and account it belongs to, so a different account starts from an empty ancestor instead of deleting. The working copy is only rewritten under the app-state lock, and sync commands now run off the main thread. Sign-in happens in the browser on the server's pages, via the owner's OIDC provider and/or local username/password accounts (Argon2id, managed with `mtgc-server user ...`), and returns a per-device revocable token to the app by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on Android. The Info tab gains sign-in, the first-sync combine/replace choice, the account's devices, and JSON export/import; the top bar shows sync state. Includes a Dockerfile, Compose/Caddy example, and scripts to build and push ${DOCKER_REGISTRY}/mtgc/sync-server.
2026-10-08 22:32:08 -05:00
//! 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<Inner>);
pub struct Inner {
pub config: Config,
db: Mutex<Connection>,
pub(crate) local: auth::local::LocalAuth,
pub(crate) oidc: Option<auth::oidc::Oidc>,
}
impl AppState {
pub fn new(config: Config, conn: Connection) -> anyhow::Result<Self> {
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<AppState>) -> Json<serde_json::Value> {
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<String>) -> 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<rusqlite::Error> 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<const N: usize>() -> [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()
}