The app can keep any number of named libraries and switch between them from the top bar or Info → Libraries. Each can sync to the account's other devices or stay on this one (a scratch library to try things in). The sync server now keeps several libraries per user under /api/libraries; a migration makes each existing library the user's default "Main", which /api/library keeps serving for older app builds. Every sync lines the device's libraries up with the account's: new ones elsewhere are downloaded, new ones here created on the server, ones deleted elsewhere flagged rather than thrown away. The existing library keeps its files and merge base, so upgrading asks nothing.
167 lines
5.6 KiB
Rust
167 lines
5.6 KiB
Rust
//! mtgc-server — keeps each user's library so their devices can sync it.
|
|
//!
|
|
//! The server is deliberately dumb about libraries: it stores versioned
|
|
//! documents — a user can have several, by name — 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_default).put(library::put_default))
|
|
.route("/api/libraries", get(library::list).post(library::create))
|
|
.route(
|
|
"/api/libraries/{id}",
|
|
get(library::get_doc).put(library::put_doc).patch(library::rename).delete(library::remove),
|
|
)
|
|
.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,
|
|
// Named libraries at /api/libraries. Advertised rather than bumping
|
|
// `api`, which older apps take as "this server is too new for me".
|
|
"libraries": true,
|
|
"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()
|
|
}
|