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:
258
crates/mtgc-core/src/sync/account.rs
Normal file
258
crates/mtgc-core/src/sync/account.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
//! The account side of the sync server: checking an address, finishing a
|
||||
//! sign-in, and managing the account's devices.
|
||||
//!
|
||||
//! 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>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user