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
|
|
|
//! The account side of the sync server: checking an address, finishing a
|
2026-10-16 21:44:55 -05:00
|
|
|
//! sign-in, and managing the account's devices and libraries.
|
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
|
|
|
//!
|
|
|
|
|
//! The sign-in itself happens in the browser, on the server's own pages (see
|
|
|
|
|
//! the server's `auth` module); the app only starts it and redeems the
|
|
|
|
|
//! single-use code it gets back. What the app proves when redeeming is that it
|
|
|
|
|
//! is the one that started the sign-in — PKCE, RFC 7636 — which is what makes
|
|
|
|
|
//! it safe for that code to travel through a browser and a URL scheme.
|
|
|
|
|
|
|
|
|
|
use base64::Engine;
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use sha2::{Digest, Sha256};
|
|
|
|
|
|
|
|
|
|
use super::remote::HttpRemote;
|
|
|
|
|
use crate::error::{CoreError, Result};
|
|
|
|
|
use crate::http::{agent, USER_AGENT};
|
|
|
|
|
|
|
|
|
|
/// What a server says about itself, before anyone is signed in.
|
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
|
|
|
pub struct ServerInfo {
|
|
|
|
|
pub server: String,
|
|
|
|
|
pub api: u32,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub local_auth: bool,
|
|
|
|
|
/// The identity provider's display name, when single sign-on is on.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub oidc: Option<String>,
|
2026-10-16 21:44:55 -05:00
|
|
|
/// Whether an account can keep several named libraries. Servers from
|
|
|
|
|
/// before then keep one, at `/api/library`.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub libraries: bool,
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The newest server API this build speaks.
|
|
|
|
|
pub const API_VERSION: u32 = 1;
|
|
|
|
|
|
|
|
|
|
/// Tidy a server address as typed: trim it, drop trailing slashes, and assume
|
|
|
|
|
/// `https://` when no scheme is given.
|
|
|
|
|
pub fn normalize_server_url(input: &str) -> String {
|
|
|
|
|
let s = input.trim().trim_end_matches('/');
|
|
|
|
|
if s.contains("://") {
|
|
|
|
|
s.to_string()
|
|
|
|
|
} else {
|
|
|
|
|
format!("https://{s}")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Confirm `server_url` is an mtgc sync server this build can talk to, before
|
|
|
|
|
/// sending the user's browser there.
|
|
|
|
|
pub fn server_info(server_url: &str) -> Result<ServerInfo> {
|
|
|
|
|
let url = format!("{server_url}/api/info");
|
|
|
|
|
let info: ServerInfo = agent()
|
|
|
|
|
.get(&url)
|
|
|
|
|
.set("User-Agent", USER_AGENT)
|
|
|
|
|
.set("Accept", "application/json")
|
|
|
|
|
.call()
|
|
|
|
|
.map_err(|e| CoreError::Http(format!("couldn't reach {server_url}: {e}")))?
|
|
|
|
|
.into_json()
|
|
|
|
|
.map_err(|_| not_a_server(server_url))?;
|
|
|
|
|
if info.server != "mtgc" {
|
|
|
|
|
return Err(not_a_server(server_url));
|
|
|
|
|
}
|
|
|
|
|
if info.api > API_VERSION {
|
|
|
|
|
return Err(CoreError::Other(
|
|
|
|
|
"that sync server is newer than this app — update the app first".into(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(info)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn not_a_server(url: &str) -> CoreError {
|
|
|
|
|
CoreError::Other(format!("{url} doesn't look like an mtgc sync server"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A PKCE verifier and its S256 challenge.
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct Pkce {
|
|
|
|
|
pub verifier: String,
|
|
|
|
|
pub challenge: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Pkce {
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
let verifier = random_token();
|
|
|
|
|
let challenge = Self::challenge_for(&verifier);
|
|
|
|
|
Self { verifier, challenge }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// base64url(SHA-256(verifier)), unpadded — RFC 7636 §4.2.
|
|
|
|
|
pub fn challenge_for(verifier: &str) -> String {
|
|
|
|
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for Pkce {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self::new()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 256 random bits, base64url: 43 characters, which is also exactly the
|
|
|
|
|
/// shortest PKCE verifier RFC 7636 allows.
|
|
|
|
|
pub fn random_token() -> String {
|
|
|
|
|
let mut buf = [0u8; 32];
|
|
|
|
|
getrandom::fill(&mut buf).expect("the OS random number generator failed");
|
|
|
|
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The browser address that starts a sign-in.
|
|
|
|
|
pub fn start_url(
|
|
|
|
|
server_url: &str,
|
|
|
|
|
redirect_uri: &str,
|
|
|
|
|
pkce: &Pkce,
|
|
|
|
|
state: &str,
|
|
|
|
|
device_name: &str,
|
|
|
|
|
) -> String {
|
|
|
|
|
let query: String = url_encode_pairs(&[
|
|
|
|
|
("redirect_uri", redirect_uri),
|
|
|
|
|
("code_challenge", &pkce.challenge),
|
|
|
|
|
("code_challenge_method", "S256"),
|
|
|
|
|
("state", state),
|
|
|
|
|
("device_name", device_name),
|
|
|
|
|
]);
|
|
|
|
|
format!("{server_url}/auth/start?{query}")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn url_encode_pairs(pairs: &[(&str, &str)]) -> String {
|
|
|
|
|
pairs
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(k, v)| format!("{k}={}", percent_encode(v)))
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("&")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Percent-encode everything but RFC 3986's unreserved characters.
|
|
|
|
|
fn percent_encode(s: &str) -> String {
|
|
|
|
|
let mut out = String::with_capacity(s.len());
|
|
|
|
|
for b in s.bytes() {
|
|
|
|
|
match b {
|
|
|
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(b as char),
|
|
|
|
|
_ => out.push_str(&format!("%{b:02X}")),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The account a device is signed in to.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct AccountUser {
|
|
|
|
|
/// Stable id: what a stored merge base is keyed to.
|
|
|
|
|
pub id: String,
|
|
|
|
|
pub username: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub display_name: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub email: String,
|
|
|
|
|
/// "local" or "oidc".
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub method: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The result of a completed sign-in.
|
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
|
|
|
pub struct SignedIn {
|
|
|
|
|
pub token: String,
|
|
|
|
|
pub device_id: i64,
|
|
|
|
|
pub user: AccountUser,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Trade the code from the browser, with the verifier only this app holds, for
|
|
|
|
|
/// this device's token.
|
|
|
|
|
pub fn redeem_code(server_url: &str, code: &str, verifier: &str) -> Result<SignedIn> {
|
|
|
|
|
let resp = agent()
|
|
|
|
|
.post(&format!("{server_url}/auth/token"))
|
|
|
|
|
.set("User-Agent", USER_AGENT)
|
|
|
|
|
.send_json(serde_json::json!({ "code": code, "code_verifier": verifier }));
|
|
|
|
|
match resp {
|
|
|
|
|
Ok(r) => Ok(r.into_json()?),
|
|
|
|
|
Err(ureq::Error::Status(400, _)) => Err(CoreError::Other(
|
|
|
|
|
"the sign-in expired before it could be completed — try again".into(),
|
|
|
|
|
)),
|
|
|
|
|
Err(e) => Err(e.into()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One of an account's signed-in devices.
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct DeviceInfo {
|
|
|
|
|
pub id: i64,
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub created_at: i64,
|
|
|
|
|
pub last_seen_at: i64,
|
|
|
|
|
/// Whether it is the device asking.
|
|
|
|
|
pub current: bool,
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 21:44:55 -05:00
|
|
|
/// One of an account's libraries on the server.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct RemoteLibrary {
|
|
|
|
|
pub id: String,
|
|
|
|
|
pub name: String,
|
|
|
|
|
/// The library `/api/library` serves — the one the account started with.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub default: bool,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub version: u64,
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct Me {
|
|
|
|
|
pub user: AccountUser,
|
|
|
|
|
pub devices: Vec<DeviceInfo>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl HttpRemote {
|
|
|
|
|
pub fn me(&self) -> Result<Me> {
|
|
|
|
|
Ok(self.request("GET", "/api/me").call().map_err(super::remote::http_error)?.into_json()?)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Sign another of the account's devices out.
|
|
|
|
|
pub fn revoke_device(&self, id: i64) -> Result<()> {
|
|
|
|
|
self.request("DELETE", &format!("/api/devices/{id}"))
|
|
|
|
|
.call()
|
|
|
|
|
.map_err(super::remote::http_error)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 21:44:55 -05:00
|
|
|
/// The account's libraries, the default first.
|
|
|
|
|
pub fn list_libraries(&self) -> Result<Vec<RemoteLibrary>> {
|
|
|
|
|
Ok(self.request("GET", "/api/libraries").call().map_err(super::remote::http_error)?.into_json()?)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Start a new, empty library on the server.
|
|
|
|
|
pub fn create_library(&self, name: &str) -> Result<RemoteLibrary> {
|
|
|
|
|
Ok(self
|
|
|
|
|
.request("POST", "/api/libraries")
|
|
|
|
|
.send_json(serde_json::json!({ "name": name }))
|
|
|
|
|
.map_err(super::remote::http_error)?
|
|
|
|
|
.into_json()?)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn rename_library(&self, id: &str, name: &str) -> Result<RemoteLibrary> {
|
|
|
|
|
Ok(self
|
|
|
|
|
.request("PATCH", &format!("/api/libraries/{}", super::remote::url_path_segment(id)))
|
|
|
|
|
.send_json(serde_json::json!({ "name": name }))
|
|
|
|
|
.map_err(super::remote::http_error)?
|
|
|
|
|
.into_json()?)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Delete a library from the server. Devices keep their own copies.
|
|
|
|
|
pub fn delete_library(&self, id: &str) -> Result<()> {
|
|
|
|
|
self.request("DELETE", &format!("/api/libraries/{}", super::remote::url_path_segment(id)))
|
|
|
|
|
.call()
|
|
|
|
|
.map_err(super::remote::http_error)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/// Sign this device out on the server, so its token stops working even if
|
|
|
|
|
/// a copy of it survives somewhere.
|
|
|
|
|
pub fn logout(&self) -> Result<()> {
|
|
|
|
|
self.request("POST", "/auth/logout").call().map_err(super::remote::http_error)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn server_addresses_are_tidied() {
|
|
|
|
|
assert_eq!(normalize_server_url(" mtgc.example.com/ "), "https://mtgc.example.com");
|
|
|
|
|
assert_eq!(normalize_server_url("http://10.0.2.2:8080//"), "http://10.0.2.2:8080");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn pkce_matches_the_rfc_7636_example() {
|
|
|
|
|
// Appendix B of the RFC.
|
|
|
|
|
assert_eq!(
|
|
|
|
|
Pkce::challenge_for("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
|
|
|
|
|
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
|
|
|
|
);
|
|
|
|
|
let p = Pkce::new();
|
|
|
|
|
assert_eq!(p.verifier.len(), 43);
|
|
|
|
|
assert_ne!(p.verifier, Pkce::new().verifier);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_start_url_carries_everything_encoded() {
|
|
|
|
|
let p = Pkce { verifier: "v".into(), challenge: "c-_".into() };
|
|
|
|
|
let url = start_url(
|
|
|
|
|
"https://m.example",
|
|
|
|
|
"http://127.0.0.1:5000/callback",
|
|
|
|
|
&p,
|
|
|
|
|
"s t",
|
|
|
|
|
"Garrett's phone",
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
url,
|
|
|
|
|
"https://m.example/auth/start?redirect_uri=http%3A%2F%2F127.0.0.1%3A5000%2Fcallback\
|
|
|
|
|
&code_challenge=c-_&code_challenge_method=S256&state=s%20t&device_name=Garrett%27s%20phone"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|