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` — run the sync server, or manage its local accounts.
|
|
|
|
|
//!
|
|
|
|
|
//! ```text
|
|
|
|
|
//! mtgc-server serve (the default)
|
|
|
|
|
//! mtgc-server user add <name> [--display-name <text>]
|
|
|
|
|
//! mtgc-server user passwd <name>
|
|
|
|
|
//! mtgc-server user remove <name> --yes
|
|
|
|
|
//! mtgc-server user list
|
|
|
|
|
//! ```
|
|
|
|
|
//!
|
|
|
|
|
//! The `user` commands read the same `MTGC_DB` as the server, so in a container
|
2026-10-08 23:09:34 -05:00
|
|
|
//! they run with `docker exec -it <container> mtgc-server user add me`.
|
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
|
|
|
|
|
|
|
|
use std::io::{BufRead, IsTerminal};
|
|
|
|
|
|
|
|
|
|
use anyhow::{bail, Context};
|
|
|
|
|
use mtgc_server::auth::local::{hash_password, normalize_username};
|
|
|
|
|
use mtgc_server::{db, router, AppState, Config};
|
|
|
|
|
|
|
|
|
|
const USAGE: &str = "usage:
|
|
|
|
|
mtgc-server run the server
|
|
|
|
|
mtgc-server user add <name> [--display-name <text>]
|
|
|
|
|
mtgc-server user passwd <name>
|
|
|
|
|
mtgc-server user remove <name> --yes
|
|
|
|
|
mtgc-server user list";
|
|
|
|
|
|
|
|
|
|
fn main() -> anyhow::Result<()> {
|
|
|
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
|
|
|
match args.first().map(String::as_str) {
|
|
|
|
|
None | Some("serve") => serve(),
|
|
|
|
|
Some("user") => user(&args[1..]),
|
|
|
|
|
Some("-h" | "--help" | "help") => {
|
|
|
|
|
println!("{USAGE}");
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
Some(other) => bail!("unknown command {other:?}\n{USAGE}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn serve() -> anyhow::Result<()> {
|
|
|
|
|
tracing_subscriber::fmt()
|
|
|
|
|
.with_env_filter(
|
|
|
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
|
|
|
.unwrap_or_else(|_| "mtgc_server=info".into()),
|
|
|
|
|
)
|
|
|
|
|
.init();
|
|
|
|
|
|
|
|
|
|
let config = Config::from_env()?;
|
2026-10-13 21:21:36 -05:00
|
|
|
let conn = open_at(&config.db_path)?;
|
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
|
|
|
let bind = config.bind;
|
|
|
|
|
tracing::info!(
|
|
|
|
|
public_url = %config.public_url,
|
|
|
|
|
local_auth = config.local_auth,
|
|
|
|
|
oidc = config.oidc.as_ref().map(|o| o.issuer.as_str()).unwrap_or("off"),
|
|
|
|
|
db = %config.db_path.display(),
|
|
|
|
|
"starting"
|
|
|
|
|
);
|
|
|
|
|
let state = AppState::new(config, conn)?;
|
|
|
|
|
|
|
|
|
|
tokio::runtime::Builder::new_multi_thread().enable_all().build()?.block_on(async move {
|
|
|
|
|
let listener = tokio::net::TcpListener::bind(bind)
|
|
|
|
|
.await
|
|
|
|
|
.with_context(|| format!("binding {bind}"))?;
|
|
|
|
|
tracing::info!("listening on {bind}");
|
2026-10-08 23:09:34 -05:00
|
|
|
axum::serve(listener, router(state)).with_graceful_shutdown(shutdown_signal()).await?;
|
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
|
|
|
Ok(())
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-08 23:09:34 -05:00
|
|
|
/// Resolves on Ctrl-C, or on SIGTERM — what `docker stop` and Kubernetes send.
|
|
|
|
|
/// As PID 1 in a container the process gets no default signal handling, so
|
|
|
|
|
/// without catching SIGTERM it would sit out the grace period and be killed.
|
|
|
|
|
async fn shutdown_signal() {
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
{
|
|
|
|
|
use tokio::signal::unix::{signal, SignalKind};
|
|
|
|
|
let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler");
|
|
|
|
|
tokio::select! {
|
|
|
|
|
_ = tokio::signal::ctrl_c() => {}
|
|
|
|
|
_ = term.recv() => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(unix))]
|
|
|
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
|
|
|
tracing::info!("shutting down");
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/// Account management needs only the database, not the rest of the server's
|
|
|
|
|
/// configuration, so it works before OIDC (say) is set up.
|
|
|
|
|
fn open_db() -> anyhow::Result<rusqlite::Connection> {
|
|
|
|
|
let path = std::env::var("MTGC_DB").unwrap_or_else(|_| "mtgc-server.db".into());
|
2026-10-13 21:21:36 -05:00
|
|
|
open_at(std::path::Path::new(&path))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Open the database, explaining the usual reason it can't be: a mounted
|
|
|
|
|
/// volume the server's user can't write to. SQLite's own message ("unable to
|
|
|
|
|
/// open database file") doesn't say so.
|
|
|
|
|
fn open_at(path: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
|
|
|
|
|
db::open(path).map_err(|e| {
|
|
|
|
|
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
|
|
|
|
|
let probe = dir.join(".mtgc-write-test");
|
|
|
|
|
let writable = std::fs::write(&probe, b"").and_then(|_| std::fs::remove_file(&probe));
|
|
|
|
|
let err = anyhow::Error::new(e).context(format!("opening {}", path.display()));
|
|
|
|
|
match writable {
|
|
|
|
|
Ok(()) => err,
|
|
|
|
|
Err(w) => err.context(format!(
|
|
|
|
|
"{} isn't writable by this process ({w}). In the container image the server \
|
|
|
|
|
runs as UID/GID 10001: on Kubernetes, set the pod's securityContext.fsGroup \
|
|
|
|
|
to 10001; for a bind mount, `chown 10001:10001` the host directory",
|
|
|
|
|
dir.display()
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
})
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn user(args: &[String]) -> anyhow::Result<()> {
|
|
|
|
|
let name = |i: usize| -> anyhow::Result<String> {
|
|
|
|
|
let n = normalize_username(args.get(i).map(String::as_str).unwrap_or_default());
|
|
|
|
|
if n.is_empty() {
|
|
|
|
|
bail!("a username is required\n{USAGE}");
|
|
|
|
|
}
|
|
|
|
|
if n.len() > 64 || n.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
|
|
|
|
bail!("usernames are up to 64 characters, without spaces");
|
|
|
|
|
}
|
|
|
|
|
Ok(n)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match args.first().map(String::as_str) {
|
|
|
|
|
Some("add") => {
|
|
|
|
|
let username = name(1)?;
|
|
|
|
|
let display_name = flag(args, "--display-name").unwrap_or_else(|| username.clone());
|
|
|
|
|
let conn = open_db()?;
|
|
|
|
|
if db::local_user(&conn, &username)?.is_some() {
|
|
|
|
|
bail!("{username} already exists — use `user passwd` to change the password");
|
|
|
|
|
}
|
|
|
|
|
let hash = hash_password(&read_new_password()?).map_err(anyhow::Error::msg)?;
|
|
|
|
|
db::create_local_user(&conn, &username, &display_name, &hash)?;
|
|
|
|
|
println!("Created {username}.");
|
|
|
|
|
}
|
|
|
|
|
Some("passwd") => {
|
|
|
|
|
let username = name(1)?;
|
|
|
|
|
let conn = open_db()?;
|
|
|
|
|
if db::local_user(&conn, &username)?.is_none() {
|
|
|
|
|
bail!("no local account named {username}");
|
|
|
|
|
}
|
|
|
|
|
let hash = hash_password(&read_new_password()?).map_err(anyhow::Error::msg)?;
|
|
|
|
|
db::set_local_password(&conn, &username, &hash)?;
|
|
|
|
|
println!("Password changed for {username}. Devices already signed in stay signed in.");
|
|
|
|
|
}
|
|
|
|
|
Some("remove") => {
|
|
|
|
|
let username = name(1)?;
|
|
|
|
|
if !args.iter().any(|a| a == "--yes") {
|
|
|
|
|
bail!(
|
|
|
|
|
"this deletes {username}'s account, signs out their devices, and deletes \
|
|
|
|
|
the library stored on this server (devices keep their own copies). \
|
|
|
|
|
Re-run with --yes to confirm."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if db::delete_local_user(&open_db()?, &username)? {
|
|
|
|
|
println!("Removed {username}.");
|
|
|
|
|
} else {
|
|
|
|
|
bail!("no local account named {username}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Some("list") => {
|
|
|
|
|
let users = db::list_users(&open_db()?)?;
|
|
|
|
|
if users.is_empty() {
|
|
|
|
|
println!("No accounts yet. Add one with `mtgc-server user add <name>`.");
|
|
|
|
|
}
|
|
|
|
|
for (u, devices) in users {
|
|
|
|
|
let kind = if u.iss == db::LOCAL_ISS { "local".to_string() } else { format!("oidc {}", u.iss) };
|
|
|
|
|
let who = if u.email.is_empty() { u.display_name } else { format!("{} <{}>", u.display_name, u.email) };
|
|
|
|
|
println!("{:<24} {:<8} devices: {:<3} {}", u.sub, kind, devices, who);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => bail!("{USAGE}"),
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn flag(args: &[String], name: &str) -> Option<String> {
|
|
|
|
|
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Prompt twice without echo at a terminal; otherwise read one line from
|
|
|
|
|
/// stdin, so it can be scripted (`echo "$PW" | mtgc-server user add me`).
|
|
|
|
|
fn read_new_password() -> anyhow::Result<String> {
|
|
|
|
|
let password = if std::io::stdin().is_terminal() {
|
|
|
|
|
let first = rpassword::prompt_password("New password: ")?;
|
|
|
|
|
let again = rpassword::prompt_password("Again: ")?;
|
|
|
|
|
if first != again {
|
|
|
|
|
bail!("the passwords didn't match");
|
|
|
|
|
}
|
|
|
|
|
first
|
|
|
|
|
} else {
|
|
|
|
|
let mut line = String::new();
|
|
|
|
|
std::io::stdin().lock().read_line(&mut line)?;
|
|
|
|
|
line.trim_end_matches(['\r', '\n']).to_string()
|
|
|
|
|
};
|
|
|
|
|
if password.chars().count() < 8 {
|
|
|
|
|
bail!("use at least 8 characters");
|
|
|
|
|
}
|
|
|
|
|
Ok(password)
|
|
|
|
|
}
|