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.
This commit is contained in:
172
crates/mtgc-server/src/config.rs
Normal file
172
crates/mtgc-server/src/config.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user