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.
173 lines
6.2 KiB
Rust
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");
|
|
}
|
|
}
|