Files
mtgc/crates/mtgc-server/src/config.rs
Garrett Mills de55fc634a 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

173 lines
6.2 KiB
Rust

//! Server configuration, read from the environment.
//!
//! Environment variables rather than a file because the server is meant to run
//! in a container behind a reverse proxy, where that is the native way to
//! configure things — and there are few enough settings that a file would be
//! all ceremony.
use std::net::SocketAddr;
use std::path::PathBuf;
use anyhow::{bail, Context};
#[derive(Debug, Clone)]
pub struct Config {
/// The URL users reach the server at, as the reverse proxy serves it. Used
/// to build the OIDC redirect URI, so it must match what the provider has
/// registered exactly.
pub public_url: String,
pub bind: SocketAddr,
pub db_path: PathBuf,
/// Username/password accounts, managed with `mtgc-server user ...`.
pub local_auth: bool,
pub oidc: Option<OidcConfig>,
}
#[derive(Debug, Clone)]
pub struct OidcConfig {
pub issuer: String,
pub client_id: String,
pub client_secret: String,
pub scopes: Vec<String>,
/// Shown on the sign-in button: "Sign in with <name>".
pub display_name: String,
/// When non-empty, only these (verified) addresses may sign in. Empty means
/// the provider alone decides who gets in, which is usually right: limiting
/// access to the application is a provider-side setting in Authentik and
/// Keycloak alike.
pub allowed_emails: Vec<String>,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
Self::from_lookup(|k| std::env::var(k).ok())
}
/// Read settings through `get`, so tests can supply them without touching
/// the process environment.
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
let get = |k: &str| get(k).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
let public_url = get("MTGC_PUBLIC_URL")
.context("MTGC_PUBLIC_URL is required — the https:// address the server is reached at")?
.trim_end_matches('/')
.to_string();
url::Url::parse(&public_url).context("MTGC_PUBLIC_URL isn't a valid URL")?;
let bind = get("MTGC_BIND")
.unwrap_or_else(|| "0.0.0.0:8080".into())
.parse()
.context("MTGC_BIND must look like 0.0.0.0:8080")?;
let db_path = PathBuf::from(get("MTGC_DB").unwrap_or_else(|| "mtgc-server.db".into()));
let local_auth = match get("MTGC_LOCAL_AUTH").as_deref() {
None => true,
Some(v) => parse_bool(v).context("MTGC_LOCAL_AUTH must be true or false")?,
};
let oidc = match get("MTGC_OIDC_ISSUER") {
None => None,
Some(issuer) => Some(OidcConfig {
issuer,
client_id: get("MTGC_OIDC_CLIENT_ID")
.context("MTGC_OIDC_CLIENT_ID is required when MTGC_OIDC_ISSUER is set")?,
client_secret: get("MTGC_OIDC_CLIENT_SECRET")
.context("MTGC_OIDC_CLIENT_SECRET is required when MTGC_OIDC_ISSUER is set")?,
scopes: get("MTGC_OIDC_SCOPES")
.unwrap_or_else(|| "openid email profile".into())
.split_whitespace()
.filter(|s| *s != "openid") // always requested
.map(str::to_string)
.collect(),
display_name: get("MTGC_OIDC_NAME").unwrap_or_else(|| "single sign-on".into()),
allowed_emails: get("MTGC_ALLOWED_EMAILS")
.map(|v| {
v.split(',')
.map(|e| e.trim().to_lowercase())
.filter(|e| !e.is_empty())
.collect()
})
.unwrap_or_default(),
}),
};
if !local_auth && oidc.is_none() {
bail!(
"no way to sign in: set MTGC_OIDC_ISSUER (and its client settings), \
or leave MTGC_LOCAL_AUTH enabled"
);
}
Ok(Self { public_url, bind, db_path, local_auth, oidc })
}
pub fn oidc_redirect_uri(&self) -> String {
format!("{}/auth/callback", self.public_url)
}
}
fn parse_bool(v: &str) -> Option<bool> {
match v.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
fn cfg(vars: &[(&str, &str)]) -> anyhow::Result<Config> {
let map: HashMap<String, String> =
vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
Config::from_lookup(|k| map.get(k).cloned())
}
#[test]
fn local_accounts_alone_need_only_the_public_url() {
let c = cfg(&[("MTGC_PUBLIC_URL", "https://mtgc.example/")]).unwrap();
assert!(c.local_auth);
assert!(c.oidc.is_none());
assert_eq!(c.oidc_redirect_uri(), "https://mtgc.example/auth/callback");
}
#[test]
fn disabling_every_sign_in_method_is_refused() {
let err = cfg(&[("MTGC_PUBLIC_URL", "https://m.example"), ("MTGC_LOCAL_AUTH", "false")])
.unwrap_err();
assert!(err.to_string().contains("no way to sign in"));
}
#[test]
fn oidc_needs_its_client_settings() {
let err = cfg(&[
("MTGC_PUBLIC_URL", "https://m.example"),
("MTGC_OIDC_ISSUER", "https://id.example/realms/home"),
])
.unwrap_err();
assert!(err.to_string().contains("MTGC_OIDC_CLIENT_ID"));
}
#[test]
fn oidc_settings_are_read() {
let c = cfg(&[
("MTGC_PUBLIC_URL", "https://m.example"),
("MTGC_LOCAL_AUTH", "off"),
("MTGC_OIDC_ISSUER", "https://id.example/realms/home"),
("MTGC_OIDC_CLIENT_ID", "mtgc"),
("MTGC_OIDC_CLIENT_SECRET", "s3cret"),
("MTGC_OIDC_NAME", "Authentik"),
("MTGC_ALLOWED_EMAILS", "Me@Example.com, you@example.com"),
])
.unwrap();
let o = c.oidc.unwrap();
assert!(!c.local_auth);
assert_eq!(o.scopes, vec!["email", "profile"]);
assert_eq!(o.allowed_emails, vec!["me@example.com", "you@example.com"]);
assert_eq!(o.display_name, "Authentik");
}
}