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.
|
|
|
|
|
//!
|
2026-10-16 21:44:55 -05:00
|
|
|
//! 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
|
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_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))
|
2026-10-16 21:44:55 -05:00
|
|
|
.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),
|
|
|
|
|
)
|
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
|
|
|
.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,
|
2026-10-16 21:44:55 -05:00
|
|
|
// Named libraries at /api/libraries. Advertised rather than bumping
|
|
|
|
|
// `api`, which older apps take as "this server is too new for me".
|
|
|
|
|
"libraries": true,
|
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
|
|
|
"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()
|
|
|
|
|
}
|