From de55fc634ada58a425d4bfc42b43470933e3985c Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Thu, 8 Oct 2026 22:32:08 -0500 Subject: [PATCH] Replace folder sync with an HTTP sync server, keeping the app offline-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .dockerignore | 7 + Cargo.lock | 1046 +++++++++++- Cargo.toml | 2 +- README.md | 156 +- crates/mtgc-core/Cargo.toml | 5 + crates/mtgc-core/src/sync.rs | 44 +- crates/mtgc-core/src/sync/account.rs | 258 +++ crates/mtgc-core/src/sync/remote.rs | 521 ++++++ crates/mtgc-server/Cargo.toml | 37 + crates/mtgc-server/Dockerfile | 24 + crates/mtgc-server/deploy/.env.example | 16 + crates/mtgc-server/deploy/Caddyfile | 5 + crates/mtgc-server/deploy/compose.yaml | 29 + crates/mtgc-server/src/auth/local.rs | 186 +++ crates/mtgc-server/src/auth/mod.rs | 304 ++++ crates/mtgc-server/src/auth/oidc.rs | 266 +++ crates/mtgc-server/src/config.rs | 172 ++ crates/mtgc-server/src/db.rs | 450 ++++++ crates/mtgc-server/src/lib.rs | 158 ++ crates/mtgc-server/src/library.rs | 77 + crates/mtgc-server/src/main.rs | 170 ++ crates/mtgc-server/src/pages.rs | 158 ++ crates/mtgc-server/tests/api.rs | 475 ++++++ crates/mtgc-server/tests/cli.rs | 57 + crates/mtgc-server/tests/sync_e2e.rs | 161 ++ package.json | 4 +- scripts/sync-server-image.sh | 68 + src-tauri/Cargo.toml | 5 + .../android/app/src/main/AndroidManifest.xml | 7 +- .../java/com/garrettmills/mtgc/SyncPlugin.kt | 295 ---- src-tauri/src/commands.rs | 27 +- src-tauri/src/lib.rs | 41 +- src-tauri/src/state.rs | 228 +-- src-tauri/src/sync.rs | 1440 ++++++++--------- src-tauri/tauri.conf.json | 7 + src/App.tsx | 491 +----- src/CommandPalette.tsx | 12 +- src/SyncPanel.tsx | 424 +++++ src/api.ts | 124 +- src/components.tsx | 15 + src/styles.css | 55 +- 41 files changed, 6365 insertions(+), 1662 deletions(-) create mode 100644 .dockerignore create mode 100644 crates/mtgc-core/src/sync/account.rs create mode 100644 crates/mtgc-core/src/sync/remote.rs create mode 100644 crates/mtgc-server/Cargo.toml create mode 100644 crates/mtgc-server/Dockerfile create mode 100644 crates/mtgc-server/deploy/.env.example create mode 100644 crates/mtgc-server/deploy/Caddyfile create mode 100644 crates/mtgc-server/deploy/compose.yaml create mode 100644 crates/mtgc-server/src/auth/local.rs create mode 100644 crates/mtgc-server/src/auth/mod.rs create mode 100644 crates/mtgc-server/src/auth/oidc.rs create mode 100644 crates/mtgc-server/src/config.rs create mode 100644 crates/mtgc-server/src/db.rs create mode 100644 crates/mtgc-server/src/lib.rs create mode 100644 crates/mtgc-server/src/library.rs create mode 100644 crates/mtgc-server/src/main.rs create mode 100644 crates/mtgc-server/src/pages.rs create mode 100644 crates/mtgc-server/tests/api.rs create mode 100644 crates/mtgc-server/tests/cli.rs create mode 100644 crates/mtgc-server/tests/sync_e2e.rs create mode 100755 scripts/sync-server-image.sh delete mode 100644 src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/SyncPlugin.kt create mode 100644 src/SyncPanel.tsx diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6381894 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +# Only the Rust workspace is needed to build mtgc-server. +target +node_modules +dist +src-tauri/gen/android/app/build +src-tauri/gen/android/.gradle +.git diff --git a/Cargo.lock b/Cargo.lock index fe359a1..4df09d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,18 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -225,6 +237,64 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -237,6 +307,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -267,6 +343,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -465,6 +550,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -472,8 +574,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -496,6 +600,32 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "cookie" version = "0.18.1" @@ -555,6 +685,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -598,6 +737,24 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -647,6 +804,33 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "darling" version = "0.23.0" @@ -692,6 +876,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -729,7 +924,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -799,6 +996,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -865,12 +1071,71 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.11" @@ -992,6 +1257,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -1257,6 +1538,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1266,8 +1548,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1289,8 +1573,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1389,6 +1676,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -1495,6 +1793,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1544,6 +1860,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -1557,6 +1879,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1564,6 +1887,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.8", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1787,6 +2126,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1904,6 +2252,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -1953,6 +2310,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.18" @@ -2000,6 +2363,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2011,6 +2380,21 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.2" @@ -2062,8 +2446,10 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-opener", + "url", ] [[package]] @@ -2071,16 +2457,44 @@ name = "mtgc-core" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", "flate2", + "getrandom 0.3.4", "nucleo", "rusqlite", "serde", "serde_json", + "sha2", "tempfile", "thiserror 1.0.69", "ureq", ] +[[package]] +name = "mtgc-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "base64 0.22.1", + "getrandom 0.3.4", + "http-body-util", + "mtgc-core", + "openidconnect", + "rpassword", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "ureq", + "url", +] + [[package]] name = "muda" version = "0.19.3" @@ -2132,6 +2546,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "nucleo" version = "0.5.0" @@ -2153,12 +2576,47 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2166,6 +2624,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2190,6 +2649,26 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.8", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "objc2" version = "0.6.4" @@ -2403,12 +2882,62 @@ dependencies = [ "libc", ] +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http", + "itertools", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.8", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2419,6 +2948,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "pango" version = "0.18.3" @@ -2473,6 +3026,26 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2549,6 +3122,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2623,12 +3217,30 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -2700,6 +3312,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.46" @@ -2721,6 +3389,62 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2816,6 +3540,44 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.8", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -2850,6 +3612,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rfd" version = "0.16.0" @@ -2888,6 +3660,47 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "rusqlite" version = "0.31.0" @@ -2902,6 +3715,16 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2951,6 +3774,7 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ + "web-time", "zeroize", ] @@ -2971,6 +3795,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3037,6 +3867,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3088,6 +3932,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3132,6 +3986,26 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3161,6 +4035,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -3231,10 +4117,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3251,6 +4146,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -3333,6 +4238,22 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3538,7 +4459,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -3637,6 +4558,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d489b8ecceae1cd09f6e1f7606f2095ac721cc8d54cf2f0e6bb377cc52cff6" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.1" @@ -3864,6 +4806,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.53" @@ -3894,6 +4845,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3929,10 +4889,33 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4079,6 +5062,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -4117,6 +5101,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4140,6 +5125,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -4319,6 +5334,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -4469,6 +5490,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.5" @@ -4728,6 +5759,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/Cargo.toml b/Cargo.toml index 7bb0f2e..2b2f404 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/mtgc-core", "src-tauri"] +members = ["crates/mtgc-core", "crates/mtgc-server", "src-tauri"] [workspace.package] version = "0.1.0" diff --git a/README.md b/README.md index 8bf732e..718c281 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,19 @@ in a platform-agnostic Rust core, so both platforms share it. ``` src/ React + TypeScript UI (swappable presentation) src-tauri/ Tauri v2 shell — exposes the core as typed commands -crates/mtgc-core/ Pure Rust: db, fuzzy search, filters, decks, ingester +crates/mtgc-core/ Pure Rust: db, fuzzy search, filters, decks, ingester, sync +crates/mtgc-server/ Optional sync server: one library per user, OIDC and/or passwords ``` -Two files: +Two kinds of data, both in the OS app-data dir (e.g. +`~/.local/share/com.garrettmills.mtgc/`): -- **your library `.json`** — owned cards, decks, deck contents. Small, - readable, and the only file you name or move. Put it somewhere synced; see - [Sync](#sync). +- **your library** — owned cards, decks, deck contents. Lives on each device + and works fully offline; sign in to a sync server to share it between + devices (see [Sync](#sync)). **Export library…** in the Info tab saves it as + a JSON backup. - **`reference.db`** — read-only card data built from Scryfall bulk. Bulky and - re-downloaded per device, so it's never synced. Lives in the OS app-data dir - (e.g. `~/.local/share/com.garrettmills.mtgc/`). + re-downloaded per device, so it's never synced. ## First-time setup @@ -84,8 +86,8 @@ Notes: - The `mtgc` binary is a single file but relies on the system WebKit/GTK libs being present (installed by `devel-setup.sh`). - The binary does **not** embed card data — first run still downloads the Scryfall - bulk into the OS app-data dir. Your library document defaults to that directory - too; move it into a synced folder with **New Library** (see [Sync](#sync)). + bulk into the OS app-data dir. Your library lives there too; to share it + between devices, see [Sync](#sync). - The `[profile.release]` settings in `Cargo.toml` (strip + LTO) make the release build take a few minutes but keep the binary small. @@ -119,42 +121,118 @@ Marvel Commander — isn't hidden. ## Sync -Your collection is a single JSON file — the only file you manage. Put it in a -folder your sync tool watches (Syncthing, Dropbox, Nextcloud, Seafile, or a USB -stick) and every device stays in step. Choose it with **Open Library** / -**New Library**, or from the **Info** tab. +Your library always lives on the device you're using, so the app works the +same with or without a network. To keep several devices in step, run an +[mtgc sync server](#running-the-sync-server) and sign in to it from each one: +**Info → Sync server**, enter its address, and **Sign in…**. Sign-in happens +in your browser, on the server's own page — with a username and password, or +through your single sign-on provider, whichever the server offers — and the +browser hands the app back a token for that device alone. -Editing in two places merges rather than one side overwriting the other. Sync -is a three-way merge against the last version of the document that device read, -which is what lets it tell "you changed this" from "they deleted it": edit -different cards on a phone offline and on the desktop, and both survive. When -both sides change the *same* field, the other copy's value wins and the change -is reported so you can correct it. +Editing in two places merges rather than one side overwriting the other. Each +device remembers the last version of the library it knows the server had, and +merges three ways against it — which is what lets it tell "you changed this" +from "they deleted it": edit different cards on a phone offline and on the +desktop, and both survive. When both sides change the *same* field, the +server's value wins and the change is reported so you can correct it. -It runs by itself — at launch, on every switch away from and back to the app, -and every few minutes while open. **Sync now** in the Info tab forces a pass. +Sync runs by itself — at launch, on every switch away from and back to the app, +on reconnecting, and every few minutes while open. The top bar shows where it +stands (**Synced**, **Offline — changes will sync**, …); **Sync now** in the +Info tab forces a pass. Offline, edits simply wait on the device: nothing is +lost, and they go out with the next sync that reaches the server. -Nothing here depends on a particular sync tool. If two devices publish while -both are offline, some tools leave a conflict copy behind; mtgc merges any it -finds beside the document and clears it once the contents are safely published. +The first time a device signs in to an account that already has a library, +and the device has one too, it asks whether to **combine** them (nothing from +either is lost) or **use the server's library** instead of this device's. -> **Upgrading from an older build?** Earlier versions had you manage -> `library.db` yourself. The Info tab offers **Convert old library** once; the -> original file is left untouched, so it doubles as a backup. +The Info tab also lists the account's signed-in devices, so a lost phone can be +signed out from another one. + +> **Upgrading from folder sync?** Earlier versions kept the library as a JSON +> file in a folder your sync tool watched. Your library is already on each +> device, so signing in to a server picks up from there; the first device to +> sign in uploads it. If one device's copy is out of date, use **Import library +> file…** on it to combine in the `library.json` from the synced folder. Builds +> older still had you manage `library.db` yourself; the Info tab offers +> **Import old library** once for that. + +## Running the sync server + +`mtgc-server` is a single binary with a SQLite database. Run it behind a +reverse proxy that provides HTTPS (the Android app refuses plain HTTP outside +debug builds). `crates/mtgc-server/deploy/` has a Docker Compose example with +Caddy: + +```bash +cd crates/mtgc-server/deploy +cp .env.example .env # set MTGC_PUBLIC_URL, sign-in options +docker compose up -d +docker compose exec mtgc-server mtgc-server user add yourname +``` + +To build the image yourself and push it to your own registry: + +```bash +DOCKER_REGISTRY=registry.example.com npm run sync-server:build # → registry.example.com/mtgc/sync-server:latest +DOCKER_REGISTRY=registry.example.com npm run sync-server:push +``` + +Both call `scripts/sync-server-image.sh`. `IMAGE_TAG` overrides the `latest` +tag, and `CONTAINER_ENGINE=podman` picks podman when docker is also installed. + +Configuration is all environment variables: + +| Variable | Default | | +|---|---|---| +| `MTGC_PUBLIC_URL` | *(required)* | The `https://` address users reach the server at, exactly as the proxy serves it | +| `MTGC_BIND` | `0.0.0.0:8080` | Listen address | +| `MTGC_DB` | `mtgc-server.db` | SQLite database path | +| `MTGC_LOCAL_AUTH` | `true` | Username/password accounts | +| `MTGC_OIDC_ISSUER` | — | Turns on single sign-on, e.g. `https://auth.example.com/application/o/mtgc/` | +| `MTGC_OIDC_CLIENT_ID`, `MTGC_OIDC_CLIENT_SECRET` | — | A confidential client, with redirect URI `${MTGC_PUBLIC_URL}/auth/callback` | +| `MTGC_OIDC_NAME` | `single sign-on` | Shown on the button: "Sign in with …" | +| `MTGC_OIDC_SCOPES` | `openid email profile` | | +| `MTGC_ALLOWED_EMAILS` | — | Comma-separated allow list of verified emails; unset leaves it to the provider | + +With both sign-in methods on, the sign-in page offers both; with only single +sign-on, it goes straight to the provider. Each provider identity and each +local account is its own user with its own library. + +Local accounts are managed from the command line; there's no sign-up page, so +a server on the internet can't be filled with accounts by strangers: + +```bash +mtgc-server user add [--display-name ] # prompts for a password +mtgc-server user passwd +mtgc-server user remove --yes # also deletes their server-side library +mtgc-server user list +``` + +The server keeps the last 50 versions of each library in its database +(`library_history`) as a safety net for recovering by hand. ## Develop / test ```bash -cargo test -p mtgc-core # core: db, search, filters, decks, ingest, sync merge -cargo test -p mtgc # shell: document read/write, conflict-copy matching +cargo test -p mtgc-core # core: db, search, filters, decks, ingest, sync merge + cycle +cargo test -p mtgc # shell: config, merge-base storage, sign-in listener +cargo test -p mtgc-server # server: sign-in, compare-and-swap, devices, end-to-end sync npm run build # typecheck + build frontend cargo build # build everything incl. the desktop app ``` The sync merge is a pure function over plain structs (`mtgc_core::sync::merge3`), -so the cases that matter — concurrent edits, a delete racing an edit, repeated -merges being a no-op — are unit tests rather than something you have to -reproduce across two devices. +and the sync cycle runs over a `Remote` trait (`mtgc_core::sync::remote`), so the +cases that matter — concurrent edits, a delete racing an edit, a lost reply, a +lost write race — are unit tests rather than something you have to reproduce +across two devices. `crates/mtgc-server/tests/sync_e2e.rs` then runs two +devices against the real server over HTTP. + +To try sign-in by hand, run a server locally (`MTGC_PUBLIC_URL=http://127.0.0.1:8080 +cargo run -p mtgc-server`, after `cargo run -p mtgc-server -- user add me`) and +sign in from the app with that address. From the Android emulator, the host is +`http://10.0.2.2:8080`; debug builds allow plain HTTP. To typecheck the Android Kotlin without building the APK (it doesn't invoke cargo, so it won't disturb a running dev session): @@ -251,15 +329,11 @@ data…**. The file is validated before it replaces anything. ### Sync on Android -Android can't hand out a filesystem path to a shared folder, so instead of -naming a file you grant access to a *folder* through the system picker: -**Info → Choose folder…**, then pick the one your sync tool watches. mtgc keeps -`library.json` inside it and everything in [Sync](#sync) applies unchanged. - -That grant is per-folder and persists across restarts, so the app declares **no -storage permission at all** — no "All files access" prompt. The alternative, -`MANAGE_EXTERNAL_STORAGE`, would hand the app the entire shared volume just to -reach one directory. +Signing in works as on desktop: the browser opens the server's sign-in page, +and returns to the app through the `mtgc://` URL scheme (registered by the +deep-link plugin from `plugins.deep-link` in `tauri.conf.json`). The app needs +no storage permission — the library lives in its private storage, and syncing +is network only. ## Branding diff --git a/crates/mtgc-core/Cargo.toml b/crates/mtgc-core/Cargo.toml index 2262adc..f143e60 100644 --- a/crates/mtgc-core/Cargo.toml +++ b/crates/mtgc-core/Cargo.toml @@ -23,6 +23,11 @@ ureq = { workspace = true } flate2 = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } +# Sync sign-in: PKCE (RFC 7636) needs a SHA-256 and unpadded base64url, and +# its verifier fresh randomness. All pure Rust, so Android needs nothing extra. +sha2 = "0.10" +base64 = "0.22" +getrandom = "0.3" [dev-dependencies] tempfile = "3" diff --git a/crates/mtgc-core/src/sync.rs b/crates/mtgc-core/src/sync.rs index f0d7180..859838e 100644 --- a/crates/mtgc-core/src/sync.rs +++ b/crates/mtgc-core/src/sync.rs @@ -1,11 +1,12 @@ //! The library as a portable JSON document, and the merge that reconciles two //! copies of it. //! -//! The document is what the user actually keeps: it lives in whatever folder -//! they sync, and it is the only file they ever see. The SQLite database is an -//! internal working copy that can always be rebuilt by merging a document into -//! an empty one — which is why "first run", "cache was cleared", and "the -//! document changed elsewhere" are all the same code path. +//! The document is the library's shape for exchange: what a sync server stores +//! (see [`remote`]), what a backup export writes, and what an import reads. On +//! each device the library lives in an internal SQLite working copy, which can +//! always be rebuilt by merging a document into an empty one — so "first +//! sync", "imported a backup", and "changed on another device" are all the same +//! code path. //! //! # Why a three-way merge //! @@ -18,13 +19,14 @@ //! //! # The one subtle rule //! -//! The stored ancestor must be **the remote document as last read**, never the -//! result we last published. If a publish is lost — the sync tool dropped it, -//! the process died, the drive was unplugged — the remote is still what we last +//! The stored ancestor must be **the remote document as last known**, never +//! the result we last *tried* to publish. If a publish is lost — the +//! connection dropped, the process died — the remote is still what we last //! read, so the next merge sees our edits as ours and republishes them. -//! Recording the merged result instead would make us believe those edits were -//! already shared, and the next merge would read their absence from the remote -//! as a deletion and destroy them. +//! Recording the attempted result instead would make us believe those edits +//! were already shared, and the next merge would read their absence from the +//! remote as a deletion and destroy them. (A publish the server *confirmed* is +//! known, so the ancestor may advance to it — see [`remote::sync_cycle`].) //! //! # Conflicts //! @@ -43,6 +45,9 @@ use serde::{Deserialize, Serialize}; use crate::error::Result; +pub mod account; +pub mod remote; + /// Bumped only for a breaking change to the document shape. Readers reject /// anything newer rather than silently misreading it. pub const DOC_VERSION: u32 = 1; @@ -480,6 +485,23 @@ pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> { Ok(()) } +/// Merge `theirs` into the working copy against `base`, and return the result. +/// +/// The working copy is only rewritten when the merge changed it, so the common +/// nothing-happened sync costs one read. +pub fn merge_into( + conn: &Connection, + base: &LibraryDoc, + theirs: &LibraryDoc, +) -> Result<(LibraryDoc, MergeReport)> { + let ours = read_doc(conn)?; + let (merged, report) = merge3(base, &ours, theirs); + if merged != ours { + apply_doc(conn, &merged)?; + } + Ok((merged, report)) +} + /// Render keys as a JSON array, for `json_each` to expand inside a query — /// SQLite has no array parameter, and this avoids building SQL by /// concatenation. diff --git a/crates/mtgc-core/src/sync/account.rs b/crates/mtgc-core/src/sync/account.rs new file mode 100644 index 0000000..5466516 --- /dev/null +++ b/crates/mtgc-core/src/sync/account.rs @@ -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, +} + +/// 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 { + 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::>() + .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 { + 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, +} + +impl HttpRemote { + pub fn me(&self) -> Result { + 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" + ); + } +} diff --git a/crates/mtgc-core/src/sync/remote.rs b/crates/mtgc-core/src/sync/remote.rs new file mode 100644 index 0000000..c72e8a1 --- /dev/null +++ b/crates/mtgc-core/src/sync/remote.rs @@ -0,0 +1,521 @@ +//! Syncing the library with a server that stores it, without ever needing one. +//! +//! The server is deliberately dumb: it keeps one versioned document per user +//! and accepts a write only when the writer names the version it is replacing +//! (compare-and-swap). It never merges. All reconciliation happens here, with +//! the same [`merge3`](super::merge3) the rest of the app already trusts, so +//! the working copy stays fully usable offline and simply catches up the next +//! time the server answers. +//! +//! # The base, and how far it may advance +//! +//! The ancestor for each merge ([`SyncBase`]) is the server's document as this +//! device last *knew* it to be. It advances only on knowledge: +//! +//! - after a read, to what was read; +//! - after a write the server **confirmed**, to what was written. +//! +//! A write whose outcome is unknown — the connection dropped before the reply +//! arrived — leaves the base at what was read. If the write did land, the next +//! sync reads it back and finds it equal to our own copy; if it didn't, our +//! edits are still recognisably ours and get sent again. Either way nothing is +//! read as a deletion. That is the same rule the folder-based sync was built +//! on, made enforceable by the server's version numbers. +//! +//! A base is only meaningful against the library it was read from, so it is +//! stored together with the server and account it belongs to ([`AccountBase`]) +//! and ignored anywhere else. Pointing a device at a different account then +//! starts from an empty ancestor — which never deletes anything — instead of +//! reading every difference between two unrelated libraries as an edit. + +use serde::{Deserialize, Serialize}; + +use super::{LibraryDoc, MergeReport}; +use crate::error::{CoreError, Result}; + +/// The server's document at a particular version. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct RemoteDoc { + pub version: u64, + pub doc: LibraryDoc, +} + +/// What became of a write. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PutOutcome { + /// Accepted; the document is now at this version. + Stored(u64), + /// Refused because someone else wrote first. Carries what they wrote, so + /// the caller can merge against it without another round trip. + Conflict(RemoteDoc), +} + +/// Where the library is stored. [`HttpRemote`] is the real one; tests use an +/// in-memory fake, which is what keeps the cycle testable without a network. +pub trait Remote { + fn get(&self) -> Result; + fn put(&self, if_match: u64, doc: &LibraryDoc) -> Result; +} + +/// The merge ancestor: the server's document as last known. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct SyncBase { + pub version: u64, + pub doc: LibraryDoc, +} + +/// A [`SyncBase`] together with the library it was read from. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountBase { + pub server_url: String, + pub user_id: String, + #[serde(flatten)] + pub base: SyncBase, +} + +impl AccountBase { + /// The base, if it belongs to this server and account. `None` means this + /// device has never synced with that library, which callers treat as a + /// first connection. + pub fn for_account(self, server_url: &str, user_id: &str) -> Option { + (self.server_url == server_url && self.user_id == user_id).then_some(self.base) + } +} + +/// What one cycle did. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] +pub struct CycleReport { + /// Whether the server's document was replaced. + pub published: bool, + /// The server's version once the cycle finished. + pub version: u64, + /// What changed in the working copy, summed across retries. + pub report: MergeReport, +} + +/// How many times to re-merge after losing a write race before giving up. +/// Losing more than once means another device is syncing in a tight loop; +/// the next scheduled sync will get through. +const MAX_ATTEMPTS: usize = 4; + +/// Reconcile the working copy with the server. +/// +/// `merge_local(base, theirs)` must merge `theirs` into the working copy +/// against `base` and return the merged document — in practice +/// [`merge_into`](super::merge_into), run while holding whatever lock keeps +/// the user's edits out, so none can land between reading the working copy +/// and rewriting it. The network calls happen outside it. +/// +/// `base` is advanced in place as far as is known to be true, and the caller +/// should persist it **whether or not this returns an error**: a failure after +/// a successful read still leaves a base worth keeping. +pub fn sync_cycle(remote: &R, base: &mut SyncBase, mut merge_local: F) -> Result +where + R: Remote + ?Sized, + F: FnMut(&LibraryDoc, &LibraryDoc) -> Result<(LibraryDoc, MergeReport)>, +{ + let mut theirs = remote.get()?; + let mut total = MergeReport::default(); + + for _ in 0..MAX_ATTEMPTS { + let (merged, report) = merge_local(&base.doc, &theirs.doc)?; + accumulate(&mut total, report); + + // The working copy now holds theirs merged with ours, so theirs is the + // ancestor of everything from here on. + *base = SyncBase { version: theirs.version, doc: theirs.doc.clone() }; + + if merged == theirs.doc { + return Ok(CycleReport { published: false, version: theirs.version, report: total }); + } + + match remote.put(theirs.version, &merged)? { + PutOutcome::Stored(version) => { + *base = SyncBase { version, doc: merged }; + return Ok(CycleReport { published: true, version, report: total }); + } + PutOutcome::Conflict(newer) => theirs = newer, + } + } + + Err(CoreError::Other( + "the library kept changing on the server while syncing — it will retry shortly".into(), + )) +} + +fn accumulate(total: &mut MergeReport, r: MergeReport) { + total.cards_changed += r.cards_changed; + total.decks_changed += r.decks_changed; + total.decks_removed += r.decks_removed; + total.conflicts.extend(r.conflicts); +} + +/// How to start syncing with a library this device has no base for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FirstSync { + /// Merge both with an empty ancestor: a union, in which nothing is lost. + Combine, + /// Discard this device's library and take the server's. + UseServer, +} + +/// Decide how a first sync proceeds. `None` means both sides hold a library and +/// only the user can say which they meant, so the caller must ask. +/// +/// When either side is empty there is nothing to choose between: combining is +/// then just uploading or downloading. +pub fn first_sync_plan( + local: &LibraryDoc, + remote: &LibraryDoc, + choice: Option, +) -> Option { + if local.is_empty() || remote.is_empty() { + return Some(FirstSync::Combine); + } + choice +} + +/// The library server, over HTTP. +pub struct HttpRemote { + base_url: String, + token: String, +} + +/// The server no longer accepts this device's token: it was revoked, or the +/// account removed. Distinguished so the app can show "signed out" instead of +/// a generic failure that retrying will never fix. +pub const UNAUTHORIZED: &str = "This device is no longer signed in to the sync server."; + +impl HttpRemote { + pub fn new(server_url: &str, token: &str) -> Self { + Self { base_url: server_url.trim_end_matches('/').to_string(), token: token.to_string() } + } + + pub fn server_url(&self) -> &str { + &self.base_url + } + + /// An authenticated request to `path` on the server. + pub(crate) fn request(&self, method: &str, path: &str) -> ureq::Request { + crate::http::agent() + .request(method, &format!("{}{path}", self.base_url)) + .set("User-Agent", crate::http::USER_AGENT) + .set("Authorization", &format!("Bearer {}", self.token)) + .set("Accept", "application/json") + } +} + +impl Remote for HttpRemote { + fn get(&self) -> Result { + let resp = self.request("GET", "/api/library").call().map_err(http_error)?; + parse_remote(&resp.into_string()?) + } + + fn put(&self, if_match: u64, doc: &LibraryDoc) -> Result { + let body = serde_json::to_string(doc)?; + let result = self + .request("PUT", "/api/library") + .set("Content-Type", "application/json") + .set("If-Match", &format!("\"{if_match}\"")) + .send_string(&body); + match result { + Ok(resp) => { + #[derive(Deserialize)] + struct Stored { + version: u64, + } + let stored: Stored = serde_json::from_str(&resp.into_string()?)?; + Ok(PutOutcome::Stored(stored.version)) + } + Err(ureq::Error::Status(412, resp)) => { + Ok(PutOutcome::Conflict(parse_remote(&resp.into_string()?)?)) + } + Err(e) => Err(http_error(e)), + } + } +} + +/// Parse a `{version, doc}` body, applying the same newer-format check as a +/// document read from disk. +fn parse_remote(text: &str) -> Result { + #[derive(Deserialize)] + struct Wire { + version: u64, + doc: serde_json::Value, + } + let wire: Wire = serde_json::from_str(text)?; + Ok(RemoteDoc { version: wire.version, doc: LibraryDoc::from_json(&wire.doc.to_string())? }) +} + +pub(crate) fn http_error(e: ureq::Error) -> CoreError { + match e { + ureq::Error::Status(401, _) => CoreError::Other(UNAUTHORIZED.into()), + ureq::Error::Status(code, resp) => { + let detail = resp.into_string().unwrap_or_default(); + let detail = detail.trim(); + if detail.is_empty() { + CoreError::Http(format!("the sync server answered {code}")) + } else { + CoreError::Http(format!("the sync server answered {code}: {detail}")) + } + } + other => CoreError::Http(other.to_string()), + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use super::*; + use crate::sync::{merge3, CardEntry}; + + /// A server held in memory, with the same compare-and-swap rule as the real + /// one, plus knobs for the failures worth testing. + #[derive(Default)] + struct FakeRemote { + state: RefCell, + /// The next write is applied, but the reply is lost. + drop_next_reply: RefCell, + /// Another device writes this just before our next write lands. + sneak_in: RefCell>, + puts: RefCell, + } + + impl FakeRemote { + fn holding(doc: LibraryDoc) -> Self { + let r = Self::default(); + *r.state.borrow_mut() = RemoteDoc { version: 1, doc }; + r + } + + fn doc(&self) -> LibraryDoc { + self.state.borrow().doc.clone() + } + } + + impl Remote for FakeRemote { + fn get(&self) -> Result { + Ok(self.state.borrow().clone()) + } + + fn put(&self, if_match: u64, doc: &LibraryDoc) -> Result { + *self.puts.borrow_mut() += 1; + if let Some(other) = self.sneak_in.borrow_mut().take() { + let mut s = self.state.borrow_mut(); + s.version += 1; + s.doc = other; + } + let mut s = self.state.borrow_mut(); + if s.version != if_match { + return Ok(PutOutcome::Conflict(s.clone())); + } + s.version += 1; + s.doc = doc.clone(); + if std::mem::take(&mut *self.drop_next_reply.borrow_mut()) { + return Err(CoreError::Http("connection reset".into())); + } + Ok(PutOutcome::Stored(s.version)) + } + } + + /// A device: its working copy, as a document, and its base. + struct Device { + local: LibraryDoc, + base: SyncBase, + } + + impl Device { + fn new(local: LibraryDoc) -> Self { + Self { local, base: SyncBase::default() } + } + + fn sync(&mut self, remote: &dyn Remote) -> Result { + let local = &mut self.local; + sync_cycle(remote, &mut self.base, |base, theirs| { + let (merged, report) = merge3(base, local, theirs); + *local = merged.clone(); + Ok((merged, report)) + }) + } + + fn set(&mut self, id: &str, qty: i64) { + self.local + .cards + .insert(id.into(), CardEntry { quantity: qty, location: String::new() }); + } + } + + fn doc(cards: &[(&str, i64)]) -> LibraryDoc { + let mut d = LibraryDoc::default(); + for (id, qty) in cards { + d.cards.insert((*id).into(), CardEntry { quantity: *qty, location: String::new() }); + } + d + } + + fn qty(d: &LibraryDoc, id: &str) -> Option { + d.cards.get(id).map(|c| c.quantity) + } + + #[test] + fn edits_made_offline_on_two_devices_both_reach_the_server() { + let server = FakeRemote::default(); + let mut phone = Device::new(doc(&[("a", 1), ("b", 1)])); + phone.sync(&server).unwrap(); + let mut desktop = Device::new(LibraryDoc::default()); + desktop.sync(&server).unwrap(); + + // Both go offline and edit different cards. + phone.set("a", 3); + desktop.set("b", 5); + + phone.sync(&server).unwrap(); + desktop.sync(&server).unwrap(); + phone.sync(&server).unwrap(); + + for d in [&phone.local, &desktop.local, &server.doc()] { + assert_eq!(qty(d, "a"), Some(3)); + assert_eq!(qty(d, "b"), Some(5)); + } + } + + #[test] + fn a_sync_with_nothing_to_contribute_does_not_write() { + let server = FakeRemote::holding(doc(&[("a", 1)])); + let mut d = Device::new(LibraryDoc::default()); + d.sync(&server).unwrap(); + let before = *server.puts.borrow(); + + let r = d.sync(&server).unwrap(); + + assert!(!r.published); + assert_eq!(*server.puts.borrow(), before, "an idle sync must not bump the version"); + } + + #[test] + fn a_lost_reply_neither_deletes_nor_strands_the_edit() { + let server = FakeRemote::default(); + let mut d = Device::new(doc(&[("a", 1)])); + d.sync(&server).unwrap(); + + d.set("a", 4); + d.set("b", 2); + *server.drop_next_reply.borrow_mut() = true; + assert!(d.sync(&server).is_err(), "the caller should hear it failed"); + + // The write did land. The base must not have advanced to it on faith, + // but rereading it has to settle quietly either way. + let r = d.sync(&server).unwrap(); + assert!(!r.published, "the server already has it"); + assert_eq!(qty(&d.local, "a"), Some(4)); + assert_eq!(qty(&d.local, "b"), Some(2)); + assert_eq!(d.base.doc, server.doc()); + } + + #[test] + fn a_write_that_never_landed_is_sent_again() { + // The mirror of the case above: the request died before the server + // saw it, so the server is unchanged. + struct Unreachable<'a>(&'a FakeRemote); + impl Remote for Unreachable<'_> { + fn get(&self) -> Result { + self.0.get() + } + fn put(&self, _: u64, _: &LibraryDoc) -> Result { + Err(CoreError::Http("network unreachable".into())) + } + } + + let server = FakeRemote::holding(doc(&[("a", 1), ("b", 1)])); + let mut d = Device::new(LibraryDoc::default()); + d.sync(&server).unwrap(); + + d.set("a", 7); + assert!(d.sync(&Unreachable(&server)).is_err()); + assert_eq!(qty(&server.doc(), "a"), Some(1)); + + let r = d.sync(&server).unwrap(); + assert!(r.published, "the edit is still ours, so it must go out again"); + assert_eq!(qty(&server.doc(), "a"), Some(7)); + assert_eq!(qty(&server.doc(), "b"), Some(1), "nothing may be read as deleted"); + } + + #[test] + fn losing_a_write_race_re_merges_instead_of_overwriting() { + let server = FakeRemote::holding(doc(&[("a", 1), ("b", 1)])); + let mut d = Device::new(LibraryDoc::default()); + d.sync(&server).unwrap(); + + d.set("a", 2); + // Another device publishes its own edit between our read and our write. + *server.sneak_in.borrow_mut() = Some(doc(&[("a", 1), ("b", 9)])); + + let r = d.sync(&server).unwrap(); + + assert!(r.published); + assert_eq!(qty(&server.doc(), "a"), Some(2), "our edit"); + assert_eq!(qty(&server.doc(), "b"), Some(9), "their edit survives too"); + assert_eq!(d.local, server.doc()); + } + + #[test] + fn a_base_from_another_account_or_server_is_ignored() { + let stored = AccountBase { + server_url: "https://sync.example".into(), + user_id: "u1".into(), + base: SyncBase { version: 3, doc: doc(&[("a", 1)]) }, + }; + + assert!(stored.clone().for_account("https://sync.example", "u1").is_some()); + assert!(stored.clone().for_account("https://sync.example", "u2").is_none()); + assert!(stored.for_account("https://other.example", "u1").is_none()); + } + + #[test] + fn an_account_base_round_trips_through_json() { + let stored = AccountBase { + server_url: "https://sync.example".into(), + user_id: "u1".into(), + base: SyncBase { version: 3, doc: doc(&[("a", 1)]) }, + }; + let text = serde_json::to_string(&stored).unwrap(); + assert_eq!(serde_json::from_str::(&text).unwrap(), stored); + } + + #[test] + fn first_sync_only_asks_when_both_sides_have_a_library() { + let some = doc(&[("a", 1)]); + let none = LibraryDoc::default(); + + assert_eq!(first_sync_plan(&none, &some, None), Some(FirstSync::Combine)); + assert_eq!(first_sync_plan(&some, &none, None), Some(FirstSync::Combine)); + assert_eq!(first_sync_plan(&some, &some, None), None, "only the user can choose"); + assert_eq!( + first_sync_plan(&some, &some, Some(FirstSync::UseServer)), + Some(FirstSync::UseServer) + ); + } + + #[test] + fn combining_on_first_sync_loses_nothing_from_either_side() { + // An empty base is what "combine" means: no difference reads as a + // deletion. + let server = FakeRemote::holding(doc(&[("a", 1), ("shared", 2)])); + let mut d = Device::new(doc(&[("b", 3), ("shared", 2)])); + + d.sync(&server).unwrap(); + + for id in ["a", "b", "shared"] { + assert!(server.doc().cards.contains_key(id), "{id} should survive"); + } + assert_eq!(d.local, server.doc()); + } + + #[test] + fn a_newer_format_from_the_server_is_refused() { + let err = parse_remote(r#"{"version": 4, "doc": {"version": 99}}"#).unwrap_err(); + assert!(err.to_string().contains("newer version"), "got: {err}"); + } +} diff --git a/crates/mtgc-server/Cargo.toml b/crates/mtgc-server/Cargo.toml new file mode 100644 index 0000000..fbb94b8 --- /dev/null +++ b/crates/mtgc-server/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "mtgc-server" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Sync server for the mtgc library catalog: one versioned library per user, OIDC and/or local sign-in." + +[lib] +name = "mtgc_server" +path = "src/lib.rs" + +[[bin]] +name = "mtgc-server" +path = "src/main.rs" + +[dependencies] +mtgc-core = { path = "../mtgc-core" } +rusqlite = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +axum = { version = "0.8", features = ["form", "json", "query", "tokio", "http1"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "sync"] } +openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] } +argon2 = "0.5" +sha2 = "0.10" +base64 = "0.22" +getrandom = "0.3" +url = "2" +rpassword = "7" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +ureq = { workspace = true } diff --git a/crates/mtgc-server/Dockerfile b/crates/mtgc-server/Dockerfile new file mode 100644 index 0000000..d1f0c93 --- /dev/null +++ b/crates/mtgc-server/Dockerfile @@ -0,0 +1,24 @@ +# Build from the repository root, so the workspace (and its lockfile) is in +# the context: +# +# docker build -f crates/mtgc-server/Dockerfile -t mtgc-server . + +FROM rust:1-bookworm AS build +WORKDIR /src +COPY . . +RUN cargo build --release --locked -p mtgc-server \ + && cp target/release/mtgc-server /mtgc-server + +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 10001 --home /data mtgc \ + && mkdir /data && chown mtgc /data +COPY --from=build /mtgc-server /usr/local/bin/mtgc-server +USER mtgc +VOLUME /data +ENV MTGC_DB=/data/mtgc-server.db \ + MTGC_BIND=0.0.0.0:8080 +EXPOSE 8080 +CMD ["mtgc-server"] diff --git a/crates/mtgc-server/deploy/.env.example b/crates/mtgc-server/deploy/.env.example new file mode 100644 index 0000000..f884f24 --- /dev/null +++ b/crates/mtgc-server/deploy/.env.example @@ -0,0 +1,16 @@ +# Where users reach the server, exactly as the reverse proxy serves it. +MTGC_PUBLIC_URL=https://mtgc.example.com + +# Username/password accounts, managed with `mtgc-server user ...`. +# Set to false to allow single sign-on only. +MTGC_LOCAL_AUTH=true + +# Single sign-on (optional). Register a confidential OIDC client with the +# redirect URI ${MTGC_PUBLIC_URL}/auth/callback. +#MTGC_OIDC_ISSUER=https://auth.example.com/application/o/mtgc/ +#MTGC_OIDC_CLIENT_ID= +#MTGC_OIDC_CLIENT_SECRET= +#MTGC_OIDC_NAME=Authentik +#MTGC_OIDC_SCOPES=openid email profile +# Comma-separated; leave unset to let the provider decide who may sign in. +#MTGC_ALLOWED_EMAILS= diff --git a/crates/mtgc-server/deploy/Caddyfile b/crates/mtgc-server/deploy/Caddyfile new file mode 100644 index 0000000..b83e2ae --- /dev/null +++ b/crates/mtgc-server/deploy/Caddyfile @@ -0,0 +1,5 @@ +# Replace with the hostname in MTGC_PUBLIC_URL. Caddy obtains the certificate. +mtgc.example.com { + encode gzip + reverse_proxy mtgc-server:8080 +} diff --git a/crates/mtgc-server/deploy/compose.yaml b/crates/mtgc-server/deploy/compose.yaml new file mode 100644 index 0000000..c8b4c6d --- /dev/null +++ b/crates/mtgc-server/deploy/compose.yaml @@ -0,0 +1,29 @@ +# Example deployment: mtgc-server behind Caddy, which provides HTTPS. +# +# cp .env.example .env # then fill it in +# docker compose up -d +# docker compose exec mtgc-server mtgc-server user add yourname + +services: + mtgc-server: + build: + context: ../../.. + dockerfile: crates/mtgc-server/Dockerfile + restart: unless-stopped + env_file: .env + volumes: + - mtgc-data:/data + + caddy: + image: caddy:2 + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + +volumes: + mtgc-data: + caddy-data: diff --git a/crates/mtgc-server/src/auth/local.rs b/crates/mtgc-server/src/auth/local.rs new file mode 100644 index 0000000..d3a8687 --- /dev/null +++ b/crates/mtgc-server/src/auth/local.rs @@ -0,0 +1,186 @@ +//! Username/password accounts, for servers without an identity provider. +//! +//! Accounts are created by whoever runs the server (`mtgc-server user add`); +//! there is no sign-up page, so a server exposed to the internet can't be +//! filled with accounts by strangers. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::Argon2; +use axum::extract::{Form, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::Response; +use serde::Deserialize; + +use crate::{auth, db, pages, AppState}; + +/// Failures allowed before each further attempt has to wait. +const FREE_FAILURES: u32 = 5; +const MAX_DELAY: Duration = Duration::from_secs(15 * 60); + +pub struct LocalAuth { + /// A real hash of nothing, verified against when the username doesn't + /// exist, so a wrong username takes as long to reject as a wrong password + /// and response times don't reveal which accounts exist. + dummy_hash: String, + failures: Mutex>, +} + +struct Failures { + count: u32, + locked_until: Instant, +} + +impl LocalAuth { + pub fn new() -> Self { + Self { + dummy_hash: hash_password("not a real password").expect("hashing works"), + failures: Mutex::new(HashMap::new()), + } + } + + /// How long until this username may try again, if it must wait. + fn wait(&self, username: &str) -> Option { + let map = self.failures.lock().unwrap_or_else(|e| e.into_inner()); + let f = map.get(username)?; + f.locked_until.checked_duration_since(Instant::now()) + } + + fn record_failure(&self, username: &str) { + let mut map = self.failures.lock().unwrap_or_else(|e| e.into_inner()); + let f = map + .entry(username.to_string()) + .or_insert(Failures { count: 0, locked_until: Instant::now() }); + f.count += 1; + if f.count >= FREE_FAILURES { + // 1s, 2s, 4s, ... capped. Slows guessing to a crawl without + // locking the real owner out for long. + let exp = (f.count - FREE_FAILURES).min(20); + let delay = Duration::from_secs(1u64 << exp).min(MAX_DELAY); + f.locked_until = Instant::now() + delay; + } + } + + fn record_success(&self, username: &str) { + self.failures.lock().unwrap_or_else(|e| e.into_inner()).remove(username); + } +} + +impl Default for LocalAuth { + fn default() -> Self { + Self::new() + } +} + +/// Argon2id with the crate's defaults (19 MiB, 2 passes): OWASP's baseline. +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| e.to_string()) +} + +fn verify_password(password: &str, hash: &str) -> bool { + PasswordHash::new(hash) + .map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok()) + .unwrap_or(false) +} + +/// Usernames are compared case-insensitively, as people expect them to be. +pub fn normalize_username(name: &str) -> String { + name.trim().to_lowercase() +} + +#[derive(Deserialize)] +pub struct LoginForm { + pending: String, + username: String, + password: String, +} + +/// The sign-in form's target. +pub async fn submit( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if !state.config().local_auth { + return pages::error(StatusCode::NOT_FOUND, "Password sign-in is turned off on this server."); + } + // The form only exists inside a sign-in the app started, in this browser; + // without one there is nowhere to send the result. + let pending = match auth::bound_pending(&state, &headers, &form.pending) { + Ok(p) => p, + Err(r) => return r, + }; + let oidc_name = state.config().oidc.as_ref().map(|o| o.display_name.clone()); + let username = normalize_username(&form.username); + + if let Some(wait) = state.0.local.wait(&username) { + let msg = format!("Too many attempts. Try again in {} seconds.", wait.as_secs().max(1)); + return pages::login(&pending, oidc_name.as_deref(), Some(&msg), &form.username); + } + + let account = match db::local_user(&state.db(), &username) { + Ok(a) => a, + Err(e) => { + tracing::error!("load local user: {e}"); + return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again."); + } + }; + + // Hashing is deliberately slow; keep it off the async workers. + let hash = account + .as_ref() + .map(|(_, h)| h.clone()) + .unwrap_or_else(|| state.0.local.dummy_hash.clone()); + let password = form.password.clone(); + let ok = tokio::task::spawn_blocking(move || verify_password(&password, &hash)) + .await + .unwrap_or(false); + + match account { + Some((user, _)) if ok => { + state.0.local.record_success(&username); + auth::finish(&state, &pending, &user) + } + _ => { + state.0.local.record_failure(&username); + tracing::warn!(username = %username, "failed password sign-in"); + pages::login(&pending, oidc_name.as_deref(), Some("Invalid username or password."), &form.username) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_password_verifies_against_its_own_hash_only() { + let h = hash_password("correct horse").unwrap(); + assert!(h.starts_with("$argon2id$")); + assert!(verify_password("correct horse", &h)); + assert!(!verify_password("wrong", &h)); + assert!(!verify_password("correct horse", "not a hash")); + } + + #[test] + fn repeated_failures_make_a_username_wait() { + let a = LocalAuth::new(); + for _ in 0..FREE_FAILURES - 1 { + a.record_failure("me"); + } + assert!(a.wait("me").is_none(), "a few typos are free"); + a.record_failure("me"); + assert!(a.wait("me").is_some()); + assert!(a.wait("someone-else").is_none(), "only that username waits"); + a.record_success("me"); + assert!(a.wait("me").is_none()); + } +} diff --git a/crates/mtgc-server/src/auth/mod.rs b/crates/mtgc-server/src/auth/mod.rs new file mode 100644 index 0000000..1ef1856 --- /dev/null +++ b/crates/mtgc-server/src/auth/mod.rs @@ -0,0 +1,304 @@ +//! Signing a device in, and recognising it afterwards. +//! +//! Every sign-in follows the same shape, whichever way the user proves who +//! they are: +//! +//! 1. The app opens the browser at `/auth/start`, with where to send the user +//! back to (`redirect_uri`, an app-only address), a PKCE challenge, and its +//! own `state`. That becomes a *pending login*. +//! 2. The user signs in on this server's page ([`local`]) or at the identity +//! provider ([`oidc`]). +//! 3. Either way ends in [`finish`]: a single-use code is sent back to the app +//! at its `redirect_uri`. +//! 4. The app redeems the code with its PKCE verifier at `/auth/token` and gets +//! a token for that device alone. +//! +//! PKCE is what makes step 3 safe on a phone: another app that registered the +//! same URL scheme could receive the code, but can't redeem it without the +//! verifier, which never left the app that started the sign-in. +//! +//! Devices hold long-lived tokens rather than the provider's own, because those +//! expire within minutes and a device that has been offline for a week must +//! still be able to sync the moment it reconnects. Each is revocable on its own. + +pub mod local; +pub mod oidc; + +use axum::extract::{FromRequestParts, Path, Query, State}; +use axum::http::header::{COOKIE, SET_COOKIE}; +use axum::http::request::Parts; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::Json; +use base64::Engine; +use serde::Deserialize; +use serde_json::json; +use sha2::{Digest, Sha256}; + +use crate::db::{self, PendingLogin, User}; +use crate::{pages, random_token, sha256_hex, ApiError, AppState}; + +/// How long the user has to finish signing in once the browser opens. +const PENDING_TTL_SECS: i64 = 15 * 60; +/// How long the app has to redeem its code. It does so immediately. +const CODE_TTL_SECS: i64 = 120; + +/// The only places a code may be sent: the desktop app's one-shot listener on +/// the loopback interface (any port, RFC 8252 §7.3), or the phone app's URL +/// scheme. Anything else is refused outright rather than redirected to, so the +/// server can never be used to deliver a code to a website. +pub fn redirect_allowed(uri: &str) -> bool { + if uri == "mtgc://auth/callback" { + return true; + } + let Ok(u) = url::Url::parse(uri) else { + return false; + }; + u.scheme() == "http" + && u.host_str() == Some("127.0.0.1") + && u.port().is_some() + && u.path() == "/callback" + && u.query().is_none() + && u.fragment().is_none() + && u.username().is_empty() + && u.password().is_none() +} + +#[derive(Deserialize)] +pub struct StartParams { + redirect_uri: String, + code_challenge: String, + #[serde(default)] + code_challenge_method: Option, + state: String, + #[serde(default)] + device_name: Option, +} + +/// Step 1: begin a sign-in and show the way to complete it. +pub async fn start(State(state): State, Query(p): Query) -> Response { + if !redirect_allowed(&p.redirect_uri) { + return pages::error(StatusCode::BAD_REQUEST, "This sign-in link didn't come from the mtgc app."); + } + if p.code_challenge_method.as_deref().unwrap_or("S256") != "S256" || p.code_challenge.len() < 43 { + return pages::error(StatusCode::BAD_REQUEST, "This version of the app isn't supported — update it."); + } + let device_name = p + .device_name + .map(|n| n.trim().chars().take(80).collect::()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| "Unnamed device".into()); + + let pending = PendingLogin { + id: random_token(), + redirect_uri: p.redirect_uri, + app_state: p.state.chars().take(200).collect(), + code_challenge: p.code_challenge, + device_name, + oidc_nonce: None, + oidc_verifier: None, + }; + if let Err(e) = db::create_pending(&state.db(), &pending, PENDING_TTL_SECS) { + tracing::error!("create pending login: {e}"); + return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again."); + } + + let c = state.config(); + let mut resp = if c.local_auth { + pages::login(&pending, c.oidc.as_ref().map(|o| o.display_name.as_str()), None, "") + } else { + // Nothing to choose between: go straight to the provider. + Redirect::to(&format!("/auth/oidc?pending={}", pending.id)).into_response() + }; + let secure = if c.public_url.starts_with("https://") { "; Secure" } else { "" }; + let cookie = format!( + "{LOGIN_COOKIE}={}; Path=/auth; HttpOnly; SameSite=Lax; Max-Age={PENDING_TTL_SECS}{secure}", + pending.id + ); + if let Ok(v) = HeaderValue::from_str(&cookie) { + resp.headers_mut().append(SET_COOKIE, v); + } + resp +} + +/// Ties a sign-in to the browser it started in. The pending login's id already +/// travels in the form and in the provider's `state`, but requiring it in a +/// cookie as well means a link or form planted on another site can't carry +/// someone's browser through a sign-in it didn't start. +const LOGIN_COOKIE: &str = "mtgc_login"; + +/// The pending login `id`, provided this browser is the one that started it. +/// Fails with the page to show instead. +#[allow(clippy::result_large_err)] // once per sign-in step; boxing buys nothing +pub(crate) fn bound_pending( + state: &AppState, + headers: &HeaderMap, + id: &str, +) -> Result { + let expired = || { + pages::error(StatusCode::BAD_REQUEST, "This sign-in has expired. Start again from the app.") + }; + let from_cookie = headers + .get_all(COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|v| v.split(';')) + .filter_map(|c| c.trim().split_once('=')) + .find(|(k, _)| *k == LOGIN_COOKIE) + .map(|(_, v)| v.to_string()); + if id.is_empty() || from_cookie.as_deref() != Some(id) { + return Err(expired()); + } + match db::pending(&state.db(), id) { + Ok(Some(p)) => Ok(p), + Ok(None) => Err(expired()), + Err(e) => { + tracing::error!("load pending login: {e}"); + Err(pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.")) + } + } +} + +/// Step 3: the user is who they say they are. Mint the app's code and send the +/// browser back to it. +pub(crate) fn finish(state: &AppState, pending: &PendingLogin, user: &User) -> Response { + let code = random_token(); + let stored = { + let conn = state.db(); + db::create_auth_code( + &conn, + &sha256_hex(&code), + user.id, + &pending.code_challenge, + &pending.device_name, + CODE_TTL_SECS, + ) + .and_then(|_| db::delete_pending(&conn, &pending.id)) + }; + if let Err(e) = stored { + tracing::error!("store auth code: {e}"); + return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again."); + } + tracing::info!(user = %user.sub, device = %pending.device_name, "signed in"); + pages::return_to_app(&back_to_app(pending, &[("code", &code)])) +} + +/// The app's redirect URI with `params` and its own `state` attached. +pub(crate) fn back_to_app(pending: &PendingLogin, params: &[(&str, &str)]) -> String { + let mut u = url::Url::parse(&pending.redirect_uri).expect("validated in start()"); + { + let mut q = u.query_pairs_mut(); + for (k, v) in params { + q.append_pair(k, v); + } + q.append_pair("state", &pending.app_state); + } + u.into() +} + +#[derive(Deserialize)] +pub struct TokenRequest { + code: String, + code_verifier: String, +} + +/// Step 4: trade the code and PKCE verifier for this device's token. +pub async fn token( + State(state): State, + Json(req): Json, +) -> Result { + let invalid = || ApiError::new(StatusCode::BAD_REQUEST, "sign-in code is invalid or expired"); + + let conn = state.db(); + let (user_id, challenge, device_name) = + db::take_auth_code(&conn, &sha256_hex(&req.code))?.ok_or_else(invalid)?; + if pkce_challenge(&req.code_verifier) != challenge { + return Err(invalid()); + } + let user = db::user_by_id(&conn, user_id)?.ok_or_else(invalid)?; + + let token = format!("mtgc_{}", random_token()); + let device_id = db::create_device(&conn, user.id, &sha256_hex(&token), &device_name)?; + Ok(Json(json!({ + "token": token, + "device_id": device_id, + "user": user_json(&user), + })) + .into_response()) +} + +/// S256 per RFC 7636: base64url(SHA-256(verifier)), unpadded. +pub fn pkce_challenge(verifier: &str) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) +} + +fn user_json(u: &User) -> serde_json::Value { + json!({ + "id": u.public_id, + "username": u.sub, + "display_name": u.display_name, + "email": u.email, + "method": if u.iss == db::LOCAL_ISS { "local" } else { "oidc" }, + }) +} + +/// A request from a signed-in device. +pub struct Authed { + pub user: User, + pub device_id: i64, +} + +impl FromRequestParts for Authed { + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let token = parts + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(str::trim) + .ok_or_else(ApiError::unauthorized)?; + let conn = state.db(); + let device = db::device_by_token(&conn, &sha256_hex(token))?.ok_or_else(ApiError::unauthorized)?; + let user = db::user_by_id(&conn, device.user_id)?.ok_or_else(ApiError::unauthorized)?; + Ok(Self { user, device_id: device.id }) + } +} + +/// The signed-in account and its devices. +pub async fn me(State(state): State, auth: Authed) -> Result { + let devices = db::devices_for(&state.db(), auth.user.id)?; + let devices: Vec<_> = devices + .iter() + .map(|d| { + json!({ + "id": d.id, + "name": d.name, + "created_at": d.created_at, + "last_seen_at": d.last_seen_at, + "current": d.id == auth.device_id, + }) + }) + .collect(); + Ok(Json(json!({ "user": user_json(&auth.user), "devices": devices })).into_response()) +} + +/// Sign this device out. +pub async fn logout(State(state): State, auth: Authed) -> Result { + db::delete_device(&state.db(), auth.user.id, auth.device_id)?; + Ok(StatusCode::NO_CONTENT) +} + +/// Sign out another of the user's devices — a lost phone, say. +pub async fn revoke_device( + State(state): State, + auth: Authed, + Path(id): Path, +) -> Result { + if db::delete_device(&state.db(), auth.user.id, id)? { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::new(StatusCode::NOT_FOUND, "no such device")) + } +} diff --git a/crates/mtgc-server/src/auth/oidc.rs b/crates/mtgc-server/src/auth/oidc.rs new file mode 100644 index 0000000..f361cf3 --- /dev/null +++ b/crates/mtgc-server/src/auth/oidc.rs @@ -0,0 +1,266 @@ +//! Signing in through the server owner's OpenID Connect provider. +//! +//! The server is a confidential client: it holds the client secret, makes the +//! code exchange itself, and checks the ID token (signature, issuer, audience, +//! nonce). The provider's tokens are used once, to learn who the user is, and +//! then discarded — devices get their own tokens (see [`super`]). + +use std::time::{Duration, Instant}; + +use axum::extract::{Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Redirect, Response}; +use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata}; +use openidconnect::{ + AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, PkceCodeChallenge, + PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, +}; +use serde::Deserialize; + +use crate::config::OidcConfig; +use crate::{auth, db, pages, AppState}; + +/// How long discovered provider metadata (including its signing keys) is +/// trusted before being fetched again, so a key rotation at the provider is +/// picked up without restarting the server. +const METADATA_TTL: Duration = Duration::from_secs(60 * 60); + +pub struct Oidc { + config: OidcConfig, + http: openidconnect::reqwest::Client, + metadata: tokio::sync::Mutex>, +} + +impl Oidc { + pub fn new(config: OidcConfig) -> anyhow::Result { + IssuerUrl::new(config.issuer.clone())?; + let http = openidconnect::reqwest::ClientBuilder::new() + // Following redirects from the token endpoint would let a + // misbehaving provider bounce the client secret elsewhere. + .redirect(openidconnect::reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(20)) + .build()?; + Ok(Self { config, http, metadata: tokio::sync::Mutex::new(None) }) + } + + /// The provider's metadata, discovered lazily so the server starts (and + /// password sign-in keeps working) even while the provider is down. + async fn metadata(&self, force: bool) -> anyhow::Result { + let mut cached = self.metadata.lock().await; + if let Some((at, meta)) = cached.as_ref() { + if !force && at.elapsed() < METADATA_TTL { + return Ok(meta.clone()); + } + } + let meta = + CoreProviderMetadata::discover_async(IssuerUrl::new(self.config.issuer.clone())?, &self.http) + .await?; + *cached = Some((Instant::now(), meta.clone())); + Ok(meta) + } + + fn client( + &self, + meta: CoreProviderMetadata, + redirect_uri: String, + ) -> anyhow::Result< + CoreClient< + openidconnect::EndpointSet, + openidconnect::EndpointNotSet, + openidconnect::EndpointNotSet, + openidconnect::EndpointNotSet, + openidconnect::EndpointMaybeSet, + openidconnect::EndpointMaybeSet, + >, + > { + Ok(CoreClient::from_provider_metadata( + meta, + ClientId::new(self.config.client_id.clone()), + Some(ClientSecret::new(self.config.client_secret.clone())), + ) + .set_redirect_uri(RedirectUrl::new(redirect_uri)?)) + } +} + +#[derive(Deserialize)] +pub struct BeginParams { + pending: String, +} + +/// Send the browser to the provider for a sign-in the app started. +pub async fn begin( + State(state): State, + headers: HeaderMap, + Query(p): Query, +) -> Response { + let Some(oidc) = state.0.oidc.as_ref() else { + return pages::error(StatusCode::NOT_FOUND, "Single sign-on isn't set up on this server."); + }; + let pending = match auth::bound_pending(&state, &headers, &p.pending) { + Ok(p) => p, + Err(r) => return r, + }; + + let meta = match oidc.metadata(false).await { + Ok(m) => m, + Err(e) => { + tracing::error!("OIDC discovery failed: {e:#}"); + return pages::error( + StatusCode::BAD_GATEWAY, + "The sign-in provider isn't reachable right now. Try again shortly.", + ); + } + }; + let client = match oidc.client(meta, state.config().oidc_redirect_uri()) { + Ok(c) => c, + Err(e) => { + tracing::error!("OIDC client: {e:#}"); + return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Single sign-on is misconfigured."); + } + }; + + let (challenge, verifier) = PkceCodeChallenge::new_random_sha256(); + let pending_id = pending.id.clone(); + let mut req = client + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + // The pending login's id is the state: it is what ties the + // provider's answer back to this sign-in. + move || CsrfToken::new(pending_id), + Nonce::new_random, + ) + .set_pkce_challenge(challenge); + for scope in &oidc.config.scopes { + req = req.add_scope(Scope::new(scope.clone())); + } + let (url, _state, nonce) = req.url(); + + if let Err(e) = db::set_pending_oidc(&state.db(), &pending.id, nonce.secret(), verifier.secret()) { + tracing::error!("store OIDC nonce: {e}"); + return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again."); + } + Redirect::to(url.as_str()).into_response() +} + +#[derive(Deserialize)] +pub struct CallbackParams { + state: Option, + code: Option, + error: Option, +} + +/// The provider's answer. +pub async fn callback( + State(state): State, + headers: HeaderMap, + Query(p): Query, +) -> Response { + let Some(oidc) = state.0.oidc.as_ref() else { + return pages::error(StatusCode::NOT_FOUND, "Single sign-on isn't set up on this server."); + }; + let pending = match auth::bound_pending(&state, &headers, p.state.as_deref().unwrap_or_default()) { + Ok(p) => p, + Err(r) => return r, + }; + + if let Some(err) = p.error { + // Declined at the provider, or the provider refused. Tell the app, so + // it stops waiting, rather than leaving the user on a dead end here. + tracing::info!("OIDC sign-in ended with {err}"); + let _ = db::delete_pending(&state.db(), &pending.id); + return pages::return_to_app(&auth::back_to_app(&pending, &[("error", "access_denied")])); + } + let (Some(code), Some(nonce), Some(verifier)) = + (p.code, pending.oidc_nonce.clone(), pending.oidc_verifier.clone()) + else { + return pages::error(StatusCode::BAD_REQUEST, "This sign-in has expired. Start again from the app."); + }; + + let identity = match identify(&state, oidc, code, nonce, verifier).await { + Ok(i) => i, + Err(e) => { + tracing::warn!("OIDC sign-in failed: {e:#}"); + return pages::error(StatusCode::BAD_GATEWAY, "Sign-in couldn't be completed. Start again from the app."); + } + }; + + let allowed = &oidc.config.allowed_emails; + if !allowed.is_empty() { + let email = identity.email.to_lowercase(); + if !identity.email_verified || !allowed.contains(&email) { + tracing::warn!(sub = %identity.sub, email = %email, "OIDC sign-in not on the allow list"); + return pages::error(StatusCode::FORBIDDEN, "This account isn't allowed to use this server."); + } + } + + let user = match db::upsert_user( + &state.db(), + &identity.iss, + &identity.sub, + &identity.display_name, + &identity.email, + ) { + Ok(u) => u, + Err(e) => { + tracing::error!("upsert OIDC user: {e}"); + return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again."); + } + }; + auth::finish(&state, &pending, &user) +} + +struct Identity { + iss: String, + sub: String, + display_name: String, + email: String, + email_verified: bool, +} + +async fn identify( + state: &AppState, + oidc: &Oidc, + code: String, + nonce: String, + verifier: String, +) -> anyhow::Result { + let meta = oidc.metadata(false).await?; + let client = oidc.client(meta, state.config().oidc_redirect_uri())?; + let response = client + .exchange_code(AuthorizationCode::new(code))? + .set_pkce_verifier(PkceCodeVerifier::new(verifier)) + .request_async(&oidc.http) + .await?; + let id_token = response + .id_token() + .ok_or_else(|| anyhow::anyhow!("the provider returned no ID token"))?; + + let nonce = Nonce::new(nonce); + let claims = match id_token.claims(&client.id_token_verifier(), &nonce) { + Ok(c) => c.clone(), + Err(first) => { + // Perhaps the provider rotated its signing keys since we last + // looked. Refetch once before giving up. + tracing::info!("ID token didn't verify ({first}); refreshing provider keys"); + let meta = oidc.metadata(true).await?; + let client = oidc.client(meta, state.config().oidc_redirect_uri())?; + let claims = id_token.claims(&client.id_token_verifier(), &nonce)?.clone(); + claims + } + }; + + let email = claims.email().map(|e| e.as_str().to_string()).unwrap_or_default(); + let display_name = claims + .name() + .and_then(|n| n.get(None)) + .map(|n| n.as_str().to_string()) + .or_else(|| claims.preferred_username().map(|u| u.as_str().to_string())) + .unwrap_or_else(|| email.clone()); + Ok(Identity { + iss: claims.issuer().as_str().to_string(), + sub: claims.subject().as_str().to_string(), + display_name, + email_verified: claims.email_verified().unwrap_or(false), + email, + }) +} diff --git a/crates/mtgc-server/src/config.rs b/crates/mtgc-server/src/config.rs new file mode 100644 index 0000000..40d7b19 --- /dev/null +++ b/crates/mtgc-server/src/config.rs @@ -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, +} + +#[derive(Debug, Clone)] +pub struct OidcConfig { + pub issuer: String, + pub client_id: String, + pub client_secret: String, + pub scopes: Vec, + /// Shown on the sign-in button: "Sign in with ". + 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, +} + +impl Config { + pub fn from_env() -> anyhow::Result { + 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) -> anyhow::Result { + 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 { + 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 { + let map: HashMap = + 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"); + } +} diff --git a/crates/mtgc-server/src/db.rs b/crates/mtgc-server/src/db.rs new file mode 100644 index 0000000..f5ac69e --- /dev/null +++ b/crates/mtgc-server/src/db.rs @@ -0,0 +1,450 @@ +//! The server's SQLite database: accounts, devices, sign-ins in progress, and +//! each user's library. +//! +//! Everything here is synchronous and runs under the one connection mutex in +//! [`crate::AppState`]. Every statement is a quick indexed lookup or a +//! single-row write, so serialising them costs nothing at the scale of a +//! personal server and rules out a whole class of races — most importantly in +//! [`put_library`], whose compare-and-swap is only sound because nothing else +//! can write between its check and its update. + +use std::path::Path; + +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::now; + +const MIGRATIONS: &[&str] = &[ + // 1: initial schema + "CREATE TABLE users ( + id INTEGER PRIMARY KEY, + -- What clients see as the account id. Random rather than the rowid so + -- a rebuilt database can't hand a device's old id to someone else. + public_id TEXT NOT NULL UNIQUE, + -- 'local' for username/password accounts (sub = username); the OIDC + -- issuer URL otherwise. + iss TEXT NOT NULL, + sub TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + password_hash TEXT, + created_at INTEGER NOT NULL, + UNIQUE (iss, sub) + ); + CREATE TABLE devices ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL + ); + CREATE TABLE pending_logins ( + id TEXT PRIMARY KEY, + redirect_uri TEXT NOT NULL, + app_state TEXT NOT NULL, + code_challenge TEXT NOT NULL, + device_name TEXT NOT NULL, + oidc_nonce TEXT, + oidc_verifier TEXT, + expires_at INTEGER NOT NULL + ); + CREATE TABLE auth_codes ( + code_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_challenge TEXT NOT NULL, + device_name TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TABLE libraries ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + doc TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE library_history ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + doc TEXT NOT NULL, + saved_at INTEGER NOT NULL, + PRIMARY KEY (user_id, version) + );", +]; + +/// How many superseded versions of each library are kept. A safety net for +/// recovering from a bad merge or a misbehaving client by hand, not a feature +/// the app exposes; a few dozen covers days of normal use. +pub const HISTORY_KEEP: i64 = 50; + +pub fn open(path: &Path) -> rusqlite::Result { + let conn = Connection::open(path)?; + conn.pragma_update(None, "journal_mode", "WAL")?; + init(conn) +} + +pub fn open_in_memory() -> rusqlite::Result { + init(Connection::open_in_memory()?) +} + +fn init(conn: Connection) -> rusqlite::Result { + conn.pragma_update(None, "foreign_keys", "ON")?; + conn.busy_timeout(std::time::Duration::from_secs(5))?; + let current: usize = conn.pragma_query_value(None, "user_version", |r| r.get(0))?; + for (i, sql) in MIGRATIONS.iter().enumerate().skip(current) { + let tx = conn.unchecked_transaction()?; + tx.execute_batch(sql)?; + tx.pragma_update(None, "user_version", i + 1)?; + tx.commit()?; + } + Ok(conn) +} + +// --- users ------------------------------------------------------------------ + +#[derive(Debug, Clone)] +pub struct User { + pub id: i64, + pub public_id: String, + pub iss: String, + pub sub: String, + pub display_name: String, + pub email: String, +} + +const USER_COLS: &str = "id, public_id, iss, sub, display_name, email"; + +fn user_row(r: &rusqlite::Row) -> rusqlite::Result { + Ok(User { + id: r.get(0)?, + public_id: r.get(1)?, + iss: r.get(2)?, + sub: r.get(3)?, + display_name: r.get(4)?, + email: r.get(5)?, + }) +} + +pub const LOCAL_ISS: &str = "local"; + +/// Create the user for an identity, or refresh the profile details of the one +/// that already exists. The identity itself (`iss`, `sub`) never changes. +pub fn upsert_user( + conn: &Connection, + iss: &str, + sub: &str, + display_name: &str, + email: &str, +) -> rusqlite::Result { + conn.execute( + "INSERT INTO users (public_id, iss, sub, display_name, email, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT (iss, sub) DO UPDATE SET + display_name = excluded.display_name, + email = excluded.email", + params![crate::random_hex(16), iss, sub, display_name, email, now()], + )?; + conn.query_row( + &format!("SELECT {USER_COLS} FROM users WHERE iss = ?1 AND sub = ?2"), + params![iss, sub], + user_row, + ) +} + +pub fn user_by_id(conn: &Connection, id: i64) -> rusqlite::Result> { + conn.query_row(&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"), [id], user_row) + .optional() +} + +/// A local account and its password hash. +pub fn local_user(conn: &Connection, username: &str) -> rusqlite::Result> { + conn.query_row( + &format!( + "SELECT {USER_COLS}, password_hash FROM users + WHERE iss = '{LOCAL_ISS}' AND sub = ?1 AND password_hash IS NOT NULL" + ), + [username], + |r| Ok((user_row(r)?, r.get(6)?)), + ) + .optional() +} + +/// Create a local account. Fails if the username is taken. +pub fn create_local_user( + conn: &Connection, + username: &str, + display_name: &str, + password_hash: &str, +) -> rusqlite::Result { + conn.execute( + "INSERT INTO users (public_id, iss, sub, display_name, password_hash, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![crate::random_hex(16), LOCAL_ISS, username, display_name, password_hash, now()], + )?; + Ok(local_user(conn, username)?.expect("just inserted").0) +} + +/// Returns whether the account exists. +pub fn set_local_password( + conn: &Connection, + username: &str, + password_hash: &str, +) -> rusqlite::Result { + Ok(conn.execute( + "UPDATE users SET password_hash = ?2 WHERE iss = 'local' AND sub = ?1", + params![username, password_hash], + )? > 0) +} + +/// Delete a local account along with its devices and library. Returns whether +/// it existed. +pub fn delete_local_user(conn: &Connection, username: &str) -> rusqlite::Result { + Ok(conn.execute("DELETE FROM users WHERE iss = 'local' AND sub = ?1", [username])? > 0) +} + +/// Every account, for `mtgc-server user list`: (user, device count). +pub fn list_users(conn: &Connection) -> rusqlite::Result> { + let mut stmt = conn.prepare(&format!( + "SELECT {USER_COLS}, (SELECT COUNT(*) FROM devices d WHERE d.user_id = users.id) + FROM users ORDER BY iss, sub" + ))?; + let rows = stmt.query_map([], |r| Ok((user_row(r)?, r.get(6)?)))?; + rows.collect() +} + +// --- devices ---------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct Device { + pub id: i64, + pub user_id: i64, + pub name: String, + pub created_at: i64, + pub last_seen_at: i64, +} + +pub fn create_device( + conn: &Connection, + user_id: i64, + token_hash: &str, + name: &str, +) -> rusqlite::Result { + let t = now(); + conn.execute( + "INSERT INTO devices (user_id, token_hash, name, created_at, last_seen_at) + VALUES (?1, ?2, ?3, ?4, ?4)", + params![user_id, token_hash, name, t], + )?; + Ok(conn.last_insert_rowid()) +} + +/// Look up the device a token belongs to, noting that it was just used. +pub fn device_by_token(conn: &Connection, token_hash: &str) -> rusqlite::Result> { + let device = conn + .query_row( + "SELECT id, user_id, name, created_at, last_seen_at FROM devices WHERE token_hash = ?1", + [token_hash], + |r| { + Ok(Device { + id: r.get(0)?, + user_id: r.get(1)?, + name: r.get(2)?, + created_at: r.get(3)?, + last_seen_at: r.get(4)?, + }) + }, + ) + .optional()?; + if let Some(d) = &device { + conn.execute("UPDATE devices SET last_seen_at = ?2 WHERE id = ?1", params![d.id, now()])?; + } + Ok(device) +} + +pub fn devices_for(conn: &Connection, user_id: i64) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT id, user_id, name, created_at, last_seen_at FROM devices + WHERE user_id = ?1 ORDER BY last_seen_at DESC", + )?; + let rows = stmt.query_map([user_id], |r| { + Ok(Device { + id: r.get(0)?, + user_id: r.get(1)?, + name: r.get(2)?, + created_at: r.get(3)?, + last_seen_at: r.get(4)?, + }) + })?; + rows.collect() +} + +/// Revoke one of a user's devices. Scoped to the user so nobody can revoke +/// someone else's. Returns whether it existed. +pub fn delete_device(conn: &Connection, user_id: i64, device_id: i64) -> rusqlite::Result { + Ok(conn.execute( + "DELETE FROM devices WHERE id = ?1 AND user_id = ?2", + params![device_id, user_id], + )? > 0) +} + +// --- sign-in in progress ---------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct PendingLogin { + pub id: String, + pub redirect_uri: String, + pub app_state: String, + pub code_challenge: String, + pub device_name: String, + pub oidc_nonce: Option, + pub oidc_verifier: Option, +} + +pub fn create_pending(conn: &Connection, p: &PendingLogin, ttl_secs: i64) -> rusqlite::Result<()> { + // Sweep expired rows here: sign-ins are rare enough that this is the only + // housekeeping the table ever needs. + let t = now(); + conn.execute("DELETE FROM pending_logins WHERE expires_at < ?1", [t])?; + conn.execute("DELETE FROM auth_codes WHERE expires_at < ?1", [t])?; + conn.execute( + "INSERT INTO pending_logins + (id, redirect_uri, app_state, code_challenge, device_name, expires_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![p.id, p.redirect_uri, p.app_state, p.code_challenge, p.device_name, t + ttl_secs], + )?; + Ok(()) +} + +pub fn pending(conn: &Connection, id: &str) -> rusqlite::Result> { + conn.query_row( + "SELECT id, redirect_uri, app_state, code_challenge, device_name, oidc_nonce, oidc_verifier + FROM pending_logins WHERE id = ?1 AND expires_at >= ?2", + params![id, now()], + |r| { + Ok(PendingLogin { + id: r.get(0)?, + redirect_uri: r.get(1)?, + app_state: r.get(2)?, + code_challenge: r.get(3)?, + device_name: r.get(4)?, + oidc_nonce: r.get(5)?, + oidc_verifier: r.get(6)?, + }) + }, + ) + .optional() +} + +pub fn set_pending_oidc( + conn: &Connection, + id: &str, + nonce: &str, + verifier: &str, +) -> rusqlite::Result<()> { + conn.execute( + "UPDATE pending_logins SET oidc_nonce = ?2, oidc_verifier = ?3 WHERE id = ?1", + params![id, nonce, verifier], + )?; + Ok(()) +} + +pub fn delete_pending(conn: &Connection, id: &str) -> rusqlite::Result<()> { + conn.execute("DELETE FROM pending_logins WHERE id = ?1", [id])?; + Ok(()) +} + +pub fn create_auth_code( + conn: &Connection, + code_hash: &str, + user_id: i64, + code_challenge: &str, + device_name: &str, + ttl_secs: i64, +) -> rusqlite::Result<()> { + conn.execute( + "INSERT INTO auth_codes (code_hash, user_id, code_challenge, device_name, expires_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![code_hash, user_id, code_challenge, device_name, now() + ttl_secs], + )?; + Ok(()) +} + +/// Consume an app code: it is deleted whether or not it turns out to be +/// redeemable, so a code can be tried exactly once. +pub fn take_auth_code( + conn: &Connection, + code_hash: &str, +) -> rusqlite::Result> { + let row = conn + .query_row( + "SELECT user_id, code_challenge, device_name FROM auth_codes + WHERE code_hash = ?1 AND expires_at >= ?2", + params![code_hash, now()], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional()?; + conn.execute("DELETE FROM auth_codes WHERE code_hash = ?1", [code_hash])?; + Ok(row) +} + +// --- libraries -------------------------------------------------------------- + +/// A user's library: (version, document JSON). Version 0 with no document +/// means nothing has been stored yet. +pub fn get_library(conn: &Connection, user_id: i64) -> rusqlite::Result> { + conn.query_row( + "SELECT version, doc FROM libraries WHERE user_id = ?1", + [user_id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional() +} + +pub enum PutResult { + Stored(i64), + /// Someone else wrote first: the current version and document. + Conflict(i64, Option), +} + +/// Replace a user's library, but only if it is still at `if_match`. +pub fn put_library( + conn: &Connection, + user_id: i64, + if_match: i64, + doc: &str, +) -> rusqlite::Result { + let tx = conn.unchecked_transaction()?; + let current = get_library(&tx, user_id)?; + let current_version = current.as_ref().map(|(v, _)| *v).unwrap_or(0); + if current_version != if_match { + return Ok(PutResult::Conflict(current_version, current.map(|(_, d)| d))); + } + + let t = now(); + if let Some((v, old)) = ¤t { + tx.execute( + "INSERT OR REPLACE INTO library_history (user_id, version, doc, saved_at) + VALUES (?1, ?2, ?3, ?4)", + params![user_id, v, old, t], + )?; + tx.execute( + "DELETE FROM library_history WHERE user_id = ?1 AND version <= ?2", + params![user_id, v - HISTORY_KEEP], + )?; + } + let version = current_version + 1; + tx.execute( + "INSERT INTO libraries (user_id, version, doc, updated_at) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT (user_id) DO UPDATE SET + version = excluded.version, doc = excluded.doc, updated_at = excluded.updated_at", + params![user_id, version, doc, t], + )?; + tx.commit()?; + Ok(PutResult::Stored(version)) +} + +pub fn history_versions(conn: &Connection, user_id: i64) -> rusqlite::Result> { + let mut stmt = conn + .prepare("SELECT version FROM library_history WHERE user_id = ?1 ORDER BY version")?; + let rows = stmt.query_map([user_id], |r| r.get(0))?; + rows.collect() +} diff --git a/crates/mtgc-server/src/lib.rs b/crates/mtgc-server/src/lib.rs new file mode 100644 index 0000000..15a78a2 --- /dev/null +++ b/crates/mtgc-server/src/lib.rs @@ -0,0 +1,158 @@ +//! mtgc-server — keeps each user's library so their devices can sync it. +//! +//! The server is deliberately dumb about libraries: it stores one versioned +//! document per user and accepts a write only when the writer names the +//! version it is replacing. All merging happens on the devices (see +//! `mtgc_core::sync::remote`), which is what lets them keep working offline and +//! keeps this side small enough to trust. +//! +//! Sign-in always happens in the browser, on pages this server serves, and +//! ends by handing the app a long-lived token for that one device. Passwords +//! therefore never pass through the app, and the app needs no knowledge of +//! which sign-in methods a server offers. + +pub mod auth; +pub mod config; +pub mod db; +mod library; +mod pages; + +use std::sync::{Arc, Mutex, MutexGuard}; + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use base64::Engine; +use rusqlite::Connection; +use sha2::{Digest, Sha256}; + +pub use config::Config; + +/// Largest library document accepted. A large collection serialises to a few +/// hundred kilobytes, so this leaves an order of magnitude of headroom while +/// still bounding what one request can make the server hold. +pub const MAX_BODY_BYTES: usize = 16 * 1024 * 1024; + +#[derive(Clone)] +pub struct AppState(Arc); + +pub struct Inner { + pub config: Config, + db: Mutex, + pub(crate) local: auth::local::LocalAuth, + pub(crate) oidc: Option, +} + +impl AppState { + pub fn new(config: Config, conn: Connection) -> anyhow::Result { + let oidc = config.oidc.clone().map(auth::oidc::Oidc::new).transpose()?; + Ok(Self(Arc::new(Inner { + config, + db: Mutex::new(conn), + local: auth::local::LocalAuth::new(), + oidc, + }))) + } + + pub fn config(&self) -> &Config { + &self.0.config + } + + /// The database. Never hold this across an `.await`. + pub fn db(&self) -> MutexGuard<'_, Connection> { + // A panic while holding the lock leaves nothing half-done that SQLite's + // own transactions don't already roll back, so carry on. + self.0.db.lock().unwrap_or_else(|e| e.into_inner()) + } +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/healthz", get(|| async { "ok" })) + .route("/api/info", get(info)) + .route("/api/library", get(library::get_library).put(library::put_library)) + .route("/api/me", get(auth::me)) + .route("/api/devices/{id}", delete(auth::revoke_device)) + .route("/auth/start", get(auth::start)) + .route("/auth/password", post(auth::local::submit)) + .route("/auth/oidc", get(auth::oidc::begin)) + .route("/auth/callback", get(auth::oidc::callback)) + .route("/auth/token", post(auth::token)) + .route("/auth/logout", post(auth::logout)) + .layer(axum::extract::DefaultBodyLimit::max(MAX_BODY_BYTES)) + .with_state(state) +} + +/// Unauthenticated, so the app can check that an address really is an mtgc +/// server before sending the user's browser there. +async fn info(axum::extract::State(state): axum::extract::State) -> Json { + let c = state.config(); + Json(serde_json::json!({ + "server": "mtgc", + "api": 1, + "local_auth": c.local_auth, + "oidc": c.oidc.as_ref().map(|o| o.display_name.clone()), + })) +} + +/// An error answered as `{"error": "..."}` with a status code. +#[derive(Debug)] +pub struct ApiError(pub StatusCode, pub String); + +impl ApiError { + pub fn new(status: StatusCode, msg: impl Into) -> Self { + Self(status, msg.into()) + } + + pub fn unauthorized() -> Self { + Self::new(StatusCode::UNAUTHORIZED, "not signed in") + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.0, Json(serde_json::json!({ "error": self.1 }))).into_response() + } +} + +impl From for ApiError { + fn from(e: rusqlite::Error) -> Self { + tracing::error!("database: {e}"); + Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error") + } +} + +pub(crate) fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn random_bytes() -> [u8; N] { + let mut buf = [0u8; N]; + getrandom::fill(&mut buf).expect("the OS random number generator failed"); + buf +} + +pub(crate) fn random_hex(bytes: usize) -> String { + let buf: [u8; 32] = random_bytes(); + hex(&buf[..bytes.min(32)]) +} + +/// A 256-bit secret, URL-safe. +pub(crate) fn random_token() -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random_bytes::<32>()) +} + +/// Tokens and codes are stored only as hashes, so a leaked database can't be +/// used to impersonate a device. SHA-256 rather than a password hash because +/// they are already 256 random bits — there is nothing to brute-force. +pub(crate) fn sha256_hex(s: &str) -> String { + hex(&Sha256::digest(s.as_bytes())) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/crates/mtgc-server/src/library.rs b/crates/mtgc-server/src/library.rs new file mode 100644 index 0000000..30a7c45 --- /dev/null +++ b/crates/mtgc-server/src/library.rs @@ -0,0 +1,77 @@ +//! The one resource: the signed-in user's library document. +//! +//! `GET` returns `{version, doc}`. `PUT` replaces the document only when its +//! `If-Match` names the current version, answering `412` with the current +//! `{version, doc}` otherwise, so the device can merge against it at once. + +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use mtgc_core::sync::LibraryDoc; +use serde_json::{json, Value}; + +use crate::auth::Authed; +use crate::db::{self, PutResult}; +use crate::{ApiError, AppState}; + +pub async fn get_library(State(state): State, auth: Authed) -> Result { + let stored = db::get_library(&state.db(), auth.user.id)?; + Ok(Json(wire(stored)?).into_response()) +} + +pub async fn put_library( + State(state): State, + auth: Authed, + headers: HeaderMap, + body: String, +) -> Result { + let if_match = if_match(&headers)?; + + // Validate with the app's own parser, so the server never stores anything + // a device would refuse to read back — including a format newer than + // this server understands. + let doc = LibraryDoc::from_json(&body) + .map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, format!("not a library: {e}")))?; + // Store it normalised rather than as sent: same content, same bytes. + let text = serde_json::to_string(&doc).map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, e.to_string()))?; + + let result = db::put_library(&state.db(), auth.user.id, if_match, &text)?; + Ok(match result { + PutResult::Stored(version) => Json(json!({ "version": version })).into_response(), + PutResult::Conflict(version, doc) => { + (StatusCode::PRECONDITION_FAILED, Json(wire(doc.map(|d| (version, d)))?)).into_response() + } + }) +} + +/// `{version, doc}`, with an empty library at version 0 for a user who has +/// never stored one. +fn wire(stored: Option<(i64, String)>) -> Result { + Ok(match stored { + Some((version, text)) => { + let doc: Value = serde_json::from_str(&text).map_err(|e| { + tracing::error!("stored library is unreadable: {e}"); + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "stored library is unreadable") + })?; + json!({ "version": version, "doc": doc }) + } + None => json!({ "version": 0, "doc": LibraryDoc::default() }), + }) +} + +/// The version a write replaces. Required: a write without one could only be +/// a blind overwrite, which is exactly what this API exists to prevent. +fn if_match(headers: &HeaderMap) -> Result { + let raw = headers + .get("if-match") + .ok_or_else(|| { + ApiError::new(StatusCode::PRECONDITION_REQUIRED, "If-Match with the version being replaced is required") + })? + .to_str() + .unwrap_or_default() + .trim(); + let raw = raw.strip_prefix("W/").unwrap_or(raw).trim_matches('"'); + raw.parse() + .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "If-Match must be a version number")) +} diff --git a/crates/mtgc-server/src/main.rs b/crates/mtgc-server/src/main.rs new file mode 100644 index 0000000..01e9caf --- /dev/null +++ b/crates/mtgc-server/src/main.rs @@ -0,0 +1,170 @@ +//! `mtgc-server` — run the sync server, or manage its local accounts. +//! +//! ```text +//! mtgc-server serve (the default) +//! mtgc-server user add [--display-name ] +//! mtgc-server user passwd +//! mtgc-server user remove --yes +//! mtgc-server user list +//! ``` +//! +//! The `user` commands read the same `MTGC_DB` as the server, so in a container +//! they run with `docker compose exec mtgc-server mtgc-server user add me`. + +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 [--display-name ] + mtgc-server user passwd + mtgc-server user remove --yes + mtgc-server user list"; + +fn main() -> anyhow::Result<()> { + let args: Vec = 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()?; + let conn = db::open(&config.db_path) + .with_context(|| format!("opening {}", config.db_path.display()))?; + 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}"); + axum::serve(listener, router(state)) + .with_graceful_shutdown(async { + let _ = tokio::signal::ctrl_c().await; + }) + .await?; + Ok(()) + }) +} + +/// 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 { + let path = std::env::var("MTGC_DB").unwrap_or_else(|_| "mtgc-server.db".into()); + db::open(std::path::Path::new(&path)).with_context(|| format!("opening {path}")) +} + +fn user(args: &[String]) -> anyhow::Result<()> { + let name = |i: usize| -> anyhow::Result { + 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 `."); + } + 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 { + 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 { + 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) +} diff --git a/crates/mtgc-server/src/pages.rs b/crates/mtgc-server/src/pages.rs new file mode 100644 index 0000000..383d18f --- /dev/null +++ b/crates/mtgc-server/src/pages.rs @@ -0,0 +1,158 @@ +//! The few HTML pages the server shows during sign-in. +//! +//! Hand-written and script-free: three small pages don't justify a template +//! engine, and with no JavaScript the content security policy can forbid it +//! outright. + +use axum::http::header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, X_FRAME_OPTIONS}; +use axum::http::{HeaderValue, StatusCode}; +use axum::response::{Html, IntoResponse, Response}; + +use crate::db::PendingLogin; + +/// The sign-in page: a username/password form, plus a button for the +/// identity provider when one is configured. `username` refills the form after +/// a failed attempt, so only the password needs retyping. +pub fn login( + pending: &PendingLogin, + oidc_name: Option<&str>, + error: Option<&str>, + username: &str, +) -> Response { + let error = error + .map(|e| format!(r#""#, esc(e))) + .unwrap_or_default(); + let oidc = oidc_name + .map(|name| { + format!( + r#"Sign in with {name} +
or
"#, + id = esc(&pending.id), + name = esc(name), + ) + }) + .unwrap_or_default(); + let body = format!( + r#"

Sign in to mtgc

+

Signing in {device} to sync your library.

+ {error} + {oidc} +
+ + + + +
"#, + device = esc(&pending.device_name), + id = esc(&pending.id), + username = esc(username), + focus_user = if username.is_empty() { " autofocus" } else { "" }, + focus_password = if username.is_empty() { "" } else { " autofocus" }, + ); + page(StatusCode::OK, "Sign in", &body, "") +} + +/// Sign-in finished: send the browser back to the app. +/// +/// A page rather than a bare redirect, because a phone browser may decline to +/// follow a redirect into an app on its own; the button then gives the user a +/// tap that it will honour. +pub fn return_to_app(url: &str) -> Response { + let url = esc(url); + let body = format!( + r#"

Returning to mtgc…

+

If the app doesn't open by itself, use the button below. You can close this tab afterwards.

+ Return to mtgc"# + ); + // Meta refresh rather than a 30x, so the page above is what's left showing + // if the hand-off doesn't happen. + let refresh = format!(r#""#); + page(StatusCode::OK, "Signed in", &body, &refresh) +} + +pub fn error(status: StatusCode, message: &str) -> Response { + let body = format!( + r#"

Sign-in didn't work

+

{}

"#, + esc(message) + ); + page(status, "Sign-in problem", &body, "") +} + +fn page(status: StatusCode, title: &str, body: &str, head_extra: &str) -> Response { + let mut resp = (status, Html(render(title, body, head_extra))).into_response(); + let h = resp.headers_mut(); + h.insert( + CONTENT_SECURITY_POLICY, + HeaderValue::from_static("default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"), + ); + // The password page must never be framed by another site. + h.insert(X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); + // Each page embeds a single-use id or code. + h.insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + resp +} + +fn render(title: &str, body: &str, head_extra: &str) -> String { + format!( + r#" + + + + + +{head_extra} +{title} · mtgc + + +
{body}
+"#, + title = esc(title), + ) +} + +/// Matches the app's terracotta brand colour, light and dark. +const STYLE: &str = r#" +:root { --bg:#faf7f5; --card:#fff; --fg:#2b2320; --muted:#6f625c; --accent:#c8553d; --accent-fg:#fff; --line:#e6ddd8; --err:#a12d1c; color-scheme: light dark; } +@media (prefers-color-scheme: dark) { :root { --bg:#1c1816; --card:#262120; --fg:#f1e9e5; --muted:#b3a59e; --line:#3d3532; --err:#ff8f7a; } } +* { box-sizing: border-box; } +body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, sans-serif; padding:16px; } +main { width:100%; max-width:380px; background:var(--card); border:1px solid var(--line); border-radius:12px; padding:28px 24px; } +h1 { font-size:1.35rem; margin:0 0 8px; } +.muted { color:var(--muted); margin:0 0 20px; } +.error { color:var(--err); margin:0 0 16px; } +form { display:flex; flex-direction:column; gap:14px; } +label { display:flex; flex-direction:column; gap:6px; font-size:.9rem; color:var(--muted); } +input { font:inherit; color:var(--fg); background:var(--bg); padding:10px 12px; border:1px solid var(--line); border-radius:8px; } +input:focus { outline:2px solid var(--accent); outline-offset:1px; } +button, .button { display:block; width:100%; text-align:center; font:inherit; font-weight:600; padding:11px; border-radius:8px; border:1px solid var(--accent); background:var(--accent); color:var(--accent-fg); cursor:pointer; text-decoration:none; } +.button.secondary { background:transparent; color:var(--accent); } +.or { display:flex; align-items:center; gap:10px; color:var(--muted); font-size:.85rem; margin:18px 0; } +.or::before, .or::after { content:""; flex:1; border-top:1px solid var(--line); } +"#; + +/// Escape text for HTML content and double-quoted attributes. +fn esc(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escaping_neutralises_markup_and_attribute_breakouts() { + assert_eq!(esc(r#""x" & 'y'"#), "<b>"x" & 'y'</b>"); + } +} diff --git a/crates/mtgc-server/tests/api.rs b/crates/mtgc-server/tests/api.rs new file mode 100644 index 0000000..3642e48 --- /dev/null +++ b/crates/mtgc-server/tests/api.rs @@ -0,0 +1,475 @@ +//! The server driven through its router in-process: sign-in end to end, the +//! library's compare-and-swap, and device revocation. + +use axum::body::Body; +use axum::http::{header, Request, Response, StatusCode}; +use axum::Router; +use http_body_util::BodyExt; +use mtgc_server::auth::local::hash_password; +use mtgc_server::auth::pkce_challenge; +use mtgc_server::{db, router, AppState, Config}; +use serde_json::{json, Value}; +use tower::ServiceExt; + +const VERIFIER: &str = "a-perfectly-good-pkce-verifier-that-is-long-enough-0123456789"; +const REDIRECT: &str = "http://127.0.0.1:43210/callback"; + +struct Server { + state: AppState, +} + +impl Server { + fn new(vars: &[(&str, &str)]) -> Self { + let mut all = vec![("MTGC_PUBLIC_URL", "https://mtgc.example")]; + all.extend_from_slice(vars); + let config = Config::from_lookup(|k| { + all.iter().find(|(name, _)| *name == k).map(|(_, v)| v.to_string()) + }) + .unwrap(); + let state = AppState::new(config, db::open_in_memory().unwrap()).unwrap(); + Self { state } + } + + fn local() -> Self { + let s = Self::new(&[]); + s.add_user("alice", "correct horse battery"); + s + } + + fn add_user(&self, name: &str, password: &str) { + let hash = hash_password(password).unwrap(); + db::create_local_user(&self.state.db(), name, name, &hash).unwrap(); + } + + fn app(&self) -> Router { + router(self.state.clone()) + } + + async fn send(&self, req: Request) -> Response { + self.app().oneshot(req).await.unwrap() + } + + /// Open the sign-in page as the app would. Returns the pending id (from + /// the cookie that binds it to this "browser") and the page. + async fn start(&self) -> (String, Response) { + let uri = format!( + "/auth/start?redirect_uri={}&code_challenge={}&code_challenge_method=S256&state=app-state&device_name=Test%20phone", + urlencode(REDIRECT), + pkce_challenge(VERIFIER), + ); + let resp = self.send(get(&uri)).await; + let cookie = resp + .headers() + .get(header::SET_COOKIE) + .expect("the sign-in should be bound to the browser") + .to_str() + .unwrap() + .to_string(); + let pending = cookie + .split(';') + .next() + .unwrap() + .strip_prefix("mtgc_login=") + .unwrap() + .to_string(); + (pending, resp) + } + + async fn submit_password(&self, pending: &str, cookie: Option<&str>, user: &str, pw: &str) -> String { + let body = format!( + "pending={}&username={}&password={}", + urlencode(pending), + urlencode(user), + urlencode(pw) + ); + let mut req = Request::post("/auth/password") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); + if let Some(c) = cookie { + req = req.header(header::COOKIE, format!("mtgc_login={c}")); + } + text(self.send(req.body(Body::from(body)).unwrap()).await).await + } + + /// Sign in with a password and return the code the app would receive. + async fn code_for(&self, user: &str, pw: &str) -> String { + let (pending, _) = self.start().await; + let page = self.submit_password(&pending, Some(&pending), user, pw).await; + code_from(&page).unwrap_or_else(|| panic!("no code in:\n{page}")) + } + + async fn redeem(&self, code: &str, verifier: &str) -> Response { + self.send(post_json("/auth/token", json!({ "code": code, "code_verifier": verifier }), None)) + .await + } + + async fn sign_in(&self, user: &str, pw: &str) -> String { + let code = self.code_for(user, pw).await; + let resp = self.redeem(&code, VERIFIER).await; + assert_eq!(resp.status(), StatusCode::OK); + body_json(resp).await["token"].as_str().unwrap().to_string() + } + + async fn get_library(&self, token: &str) -> Response { + self.send( + Request::get("/api/library") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + } + + async fn put_library(&self, token: &str, if_match: Option<&str>, doc: Value) -> Response { + let mut req = Request::put("/api/library") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json"); + if let Some(v) = if_match { + req = req.header(header::IF_MATCH, v); + } + self.send(req.body(Body::from(doc.to_string())).unwrap()).await + } +} + +fn get(uri: &str) -> Request { + Request::get(uri).body(Body::empty()).unwrap() +} + +fn post_json(uri: &str, body: Value, token: Option<&str>) -> Request { + let mut req = Request::post(uri).header(header::CONTENT_TYPE, "application/json"); + if let Some(t) = token { + req = req.header(header::AUTHORIZATION, format!("Bearer {t}")); + } + req.body(Body::from(body.to_string())).unwrap() +} + +async fn text(resp: Response) -> String { + String::from_utf8(resp.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap() +} + +async fn body_json(resp: Response) -> Value { + serde_json::from_str(&text(resp).await).unwrap() +} + +fn urlencode(s: &str) -> String { + url::form_urlencoded::byte_serialize(s.as_bytes()).collect() +} + +/// The code from the "returning to the app" page's link. +fn code_from(page: &str) -> Option { + let start = page.find(r#"href=""#)? + 6; + let href = &page[start..start + page[start..].find('"')?]; + let href = href.replace("&", "&"); + let u = url::Url::parse(&href).ok()?; + assert!(href.starts_with(REDIRECT), "code must go to the app: {href}"); + assert_eq!( + u.query_pairs().find(|(k, _)| k == "state").map(|(_, v)| v.into_owned()).as_deref(), + Some("app-state"), + "the app's state must come back with the code" + ); + u.query_pairs().find(|(k, _)| k == "code").map(|(_, v)| v.into_owned()) +} + +fn library(cards: &[(&str, i64)]) -> Value { + let cards: serde_json::Map = + cards.iter().map(|(id, q)| (id.to_string(), json!({ "quantity": q }))).collect(); + json!({ "version": 1, "cards": cards, "decks": {} }) +} + +// --- sign-in ------------------------------------------------------------------ + +#[tokio::test] +async fn a_password_sign_in_yields_a_working_device_token() { + let s = Server::local(); + let token = s.sign_in("alice", "correct horse battery").await; + + let resp = s.get_library(&token).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["version"], 0, "a new account starts empty"); + assert_eq!(body["doc"]["cards"], json!({})); + + let me = s + .send(Request::get("/api/me").header(header::AUTHORIZATION, format!("Bearer {token}")).body(Body::empty()).unwrap()) + .await; + let me = body_json(me).await; + assert_eq!(me["user"]["username"], "alice"); + assert_eq!(me["user"]["method"], "local"); + assert_eq!(me["devices"][0]["name"], "Test phone"); + assert_eq!(me["devices"][0]["current"], true); +} + +#[tokio::test] +async fn usernames_are_not_case_sensitive() { + let s = Server::local(); + s.sign_in(" Alice ", "correct horse battery").await; +} + +#[tokio::test] +async fn a_wrong_password_and_an_unknown_user_look_the_same() { + let s = Server::local(); + let (pending, _) = s.start().await; + + let wrong = s.submit_password(&pending, Some(&pending), "alice", "nope").await; + let unknown = s.submit_password(&pending, Some(&pending), "mallory", "nope").await; + + for page in [&wrong, &unknown] { + assert!(page.contains("Invalid username or password.")); + assert!(code_from(page).is_none()); + } + assert!(wrong.contains(r#"value="alice""#), "the username is kept for the retry"); +} + +#[tokio::test] +async fn repeated_failures_are_slowed_down() { + let s = Server::local(); + let (pending, _) = s.start().await; + for _ in 0..5 { + s.submit_password(&pending, Some(&pending), "alice", "nope").await; + } + // Even the right password has to wait now. + let page = s.submit_password(&pending, Some(&pending), "alice", "correct horse battery").await; + assert!(page.contains("Too many attempts"), "{page}"); +} + +#[tokio::test] +async fn a_password_post_from_another_browser_is_refused() { + let s = Server::local(); + let (pending, _) = s.start().await; + + let no_cookie = s.submit_password(&pending, None, "alice", "correct horse battery").await; + let other = s.submit_password(&pending, Some("someone-elses"), "alice", "correct horse battery").await; + + for page in [&no_cookie, &other] { + assert!(page.contains("expired"), "{page}"); + assert!(code_from(page).is_none()); + } +} + +#[tokio::test] +async fn codes_need_the_right_verifier_and_work_once() { + let s = Server::local(); + + let code = s.code_for("alice", "correct horse battery").await; + assert_eq!(s.redeem(&code, "some-other-verifier-of-a-reasonable-length-0000000").await.status(), StatusCode::BAD_REQUEST); + assert_eq!( + s.redeem(&code, VERIFIER).await.status(), + StatusCode::BAD_REQUEST, + "a failed attempt burns the code" + ); + + let code = s.code_for("alice", "correct horse battery").await; + assert_eq!(s.redeem(&code, VERIFIER).await.status(), StatusCode::OK); + assert_eq!(s.redeem(&code, VERIFIER).await.status(), StatusCode::BAD_REQUEST, "single use"); +} + +#[tokio::test] +async fn an_expired_code_is_refused() { + let s = Server::local(); + let code = s.code_for("alice", "correct horse battery").await; + s.state.db().execute("UPDATE auth_codes SET expires_at = 0", []).unwrap(); + assert_eq!(s.redeem(&code, VERIFIER).await.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn codes_are_only_ever_sent_to_the_app() { + let s = Server::local(); + for bad in [ + "https://evil.example/callback", + "http://127.0.0.1.evil.example:80/callback", + "http://localhost:1234/callback", + "http://127.0.0.1:1234/elsewhere", + "mtgc://elsewhere", + ] { + let uri = format!( + "/auth/start?redirect_uri={}&code_challenge={}&state=x", + urlencode(bad), + pkce_challenge(VERIFIER) + ); + assert_eq!(s.send(get(&uri)).await.status(), StatusCode::BAD_REQUEST, "{bad}"); + } +} + +#[tokio::test] +async fn the_phone_apps_scheme_is_accepted() { + let s = Server::local(); + let uri = format!( + "/auth/start?redirect_uri={}&code_challenge={}&state=x", + urlencode("mtgc://auth/callback"), + pkce_challenge(VERIFIER) + ); + assert_eq!(s.send(get(&uri)).await.status(), StatusCode::OK); +} + +#[tokio::test] +async fn with_only_oidc_the_server_skips_straight_to_the_provider() { + let s = Server::new(&[ + ("MTGC_LOCAL_AUTH", "false"), + ("MTGC_OIDC_ISSUER", "https://id.example/realms/home"), + ("MTGC_OIDC_CLIENT_ID", "mtgc"), + ("MTGC_OIDC_CLIENT_SECRET", "secret"), + ]); + let (pending, resp) = s.start().await; + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert_eq!( + resp.headers()[header::LOCATION].to_str().unwrap(), + format!("/auth/oidc?pending={pending}") + ); + + // And the password form is gone, not just hidden. + let page = s.submit_password(&pending, Some(&pending), "alice", "whatever").await; + assert!(page.contains("turned off"), "{page}"); +} + +#[tokio::test] +async fn with_both_methods_the_page_offers_both() { + let s = Server::new(&[ + ("MTGC_OIDC_ISSUER", "https://id.example/realms/home"), + ("MTGC_OIDC_CLIENT_ID", "mtgc"), + ("MTGC_OIDC_CLIENT_SECRET", "secret"), + ("MTGC_OIDC_NAME", "Authentik"), + ]); + let (_, resp) = s.start().await; + let page = text(resp).await; + assert!(page.contains("Sign in with Authentik")); + assert!(page.contains(r#"action="/auth/password""#)); +} + +#[tokio::test] +async fn the_server_describes_itself() { + let s = Server::local(); + let info = body_json(s.send(get("/api/info")).await).await; + assert_eq!(info["server"], "mtgc"); + assert_eq!(info["local_auth"], true); + assert_eq!(info["oidc"], Value::Null); +} + +// --- devices ------------------------------------------------------------------ + +#[tokio::test] +async fn the_library_needs_a_valid_token() { + let s = Server::local(); + assert_eq!(s.send(get("/api/library")).await.status(), StatusCode::UNAUTHORIZED); + assert_eq!(s.get_library("mtgc_made-up").await.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn signing_out_revokes_only_that_device() { + let s = Server::local(); + let phone = s.sign_in("alice", "correct horse battery").await; + let desktop = s.sign_in("alice", "correct horse battery").await; + + let resp = s.send(post_json("/auth/logout", json!({}), Some(&phone))).await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + assert_eq!(s.get_library(&phone).await.status(), StatusCode::UNAUTHORIZED); + assert_eq!(s.get_library(&desktop).await.status(), StatusCode::OK); +} + +#[tokio::test] +async fn a_lost_device_can_be_revoked_from_another_but_not_by_someone_else() { + let s = Server::local(); + s.add_user("bob", "another fine password"); + let lost = s.sign_in("alice", "correct horse battery").await; + let mine = s.sign_in("alice", "correct horse battery").await; + let bobs = s.sign_in("bob", "another fine password").await; + + let me = s + .send(Request::get("/api/me").header(header::AUTHORIZATION, format!("Bearer {mine}")).body(Body::empty()).unwrap()) + .await; + let devices = body_json(me).await["devices"].clone(); + let lost_id = devices + .as_array() + .unwrap() + .iter() + .find(|d| d["current"] == false) + .unwrap()["id"] + .as_i64() + .unwrap(); + + let revoke = |token: String| { + Request::delete(format!("/api/devices/{lost_id}")) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap() + }; + assert_eq!(s.send(revoke(bobs)).await.status(), StatusCode::NOT_FOUND, "not bob's to revoke"); + assert_eq!(s.get_library(&lost).await.status(), StatusCode::OK); + + assert_eq!(s.send(revoke(mine)).await.status(), StatusCode::NO_CONTENT); + assert_eq!(s.get_library(&lost).await.status(), StatusCode::UNAUTHORIZED); +} + +// --- the library ---------------------------------------------------------------- + +#[tokio::test] +async fn writes_must_name_the_version_they_replace() { + let s = Server::local(); + let t = s.sign_in("alice", "correct horse battery").await; + + let resp = s.put_library(&t, Some("\"0\""), library(&[("a", 1)])).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(body_json(resp).await["version"], 1); + + // A second device still thinks it's at 0: refused, and told what is there. + let resp = s.put_library(&t, Some("0"), library(&[("b", 2)])).await; + assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED); + let current = body_json(resp).await; + assert_eq!(current["version"], 1); + assert_eq!(current["doc"]["cards"]["a"]["quantity"], 1); + + let resp = s.put_library(&t, None, library(&[("b", 2)])).await; + assert_eq!(resp.status(), StatusCode::PRECONDITION_REQUIRED, "no blind overwrites"); + + let got = body_json(s.get_library(&t).await).await; + assert_eq!(got["version"], 1); + assert_eq!(got["doc"]["cards"]["a"]["quantity"], 1); +} + +#[tokio::test] +async fn a_write_to_a_library_that_does_not_exist_yet_must_say_version_zero() { + let s = Server::local(); + let t = s.sign_in("alice", "correct horse battery").await; + let resp = s.put_library(&t, Some("3"), library(&[("a", 1)])).await; + assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED); + assert_eq!(body_json(resp).await["version"], 0); +} + +#[tokio::test] +async fn only_valid_libraries_are_stored() { + let s = Server::local(); + let t = s.sign_in("alice", "correct horse battery").await; + + let newer = json!({ "version": 99, "cards": {}, "decks": {} }); + assert_eq!(s.put_library(&t, Some("0"), newer).await.status(), StatusCode::BAD_REQUEST); + assert_eq!(s.put_library(&t, Some("0"), json!(["nope"])).await.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn each_user_has_their_own_library() { + let s = Server::local(); + s.add_user("bob", "another fine password"); + let alice = s.sign_in("alice", "correct horse battery").await; + let bob = s.sign_in("bob", "another fine password").await; + + s.put_library(&alice, Some("0"), library(&[("a", 1)])).await; + + let bobs = body_json(s.get_library(&bob).await).await; + assert_eq!(bobs["version"], 0); + assert_eq!(bobs["doc"]["cards"], json!({})); +} + +#[tokio::test] +async fn old_versions_are_kept_up_to_a_limit() { + let s = Server::local(); + let t = s.sign_in("alice", "correct horse battery").await; + let total = db::HISTORY_KEEP + 10; + for v in 0..total { + let resp = s.put_library(&t, Some(&v.to_string()), library(&[("a", v)])).await; + assert_eq!(resp.status(), StatusCode::OK); + } + + let user_id: i64 = s.state.db().query_row("SELECT id FROM users WHERE sub = 'alice'", [], |r| r.get(0)).unwrap(); + let kept = db::history_versions(&s.state.db(), user_id).unwrap(); + assert_eq!(kept.len() as i64, db::HISTORY_KEEP); + assert_eq!(*kept.last().unwrap(), total - 1, "the newest superseded version is kept"); +} diff --git a/crates/mtgc-server/tests/cli.rs b/crates/mtgc-server/tests/cli.rs new file mode 100644 index 0000000..f79f0f3 --- /dev/null +++ b/crates/mtgc-server/tests/cli.rs @@ -0,0 +1,57 @@ +//! The `user` subcommands, run as the real binary against a scratch database. + +use std::io::Write; +use std::process::{Command, Output, Stdio}; + +fn run(db: &std::path::Path, args: &[&str], stdin: &str) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_mtgc-server")) + .args(args) + .env("MTGC_DB", db) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap(); + child.wait_with_output().unwrap() +} + +fn out(o: &Output) -> String { + format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) +} + +#[test] +fn local_accounts_can_be_added_changed_listed_and_removed() { + let dir = std::env::temp_dir().join(format!("mtgc-server-cli-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let db = dir.join("server.db"); + + let o = run(&db, &["user", "add", "Alice", "--display-name", "Alice A."], "a long password\n"); + assert!(o.status.success(), "{}", out(&o)); + + let o = run(&db, &["user", "add", "alice"], "a long password\n"); + assert!(!o.status.success(), "duplicate names must be refused"); + + let o = run(&db, &["user", "add", "bob"], "short\n"); + assert!(!o.status.success(), "short passwords must be refused"); + + let o = run(&db, &["user", "passwd", "alice"], "another long password\n"); + assert!(o.status.success(), "{}", out(&o)); + let conn = mtgc_server::db::open(&db).unwrap(); + let (_, hash) = mtgc_server::db::local_user(&conn, "alice").unwrap().unwrap(); + assert!(hash.starts_with("$argon2id$")); + drop(conn); + + let o = run(&db, &["user", "list"], ""); + assert!(out(&o).contains("alice") && out(&o).contains("Alice A."), "{}", out(&o)); + + let o = run(&db, &["user", "remove", "alice"], ""); + assert!(!o.status.success(), "removal must be confirmed"); + let o = run(&db, &["user", "remove", "alice", "--yes"], ""); + assert!(o.status.success(), "{}", out(&o)); + let o = run(&db, &["user", "list"], ""); + assert!(!out(&o).contains("alice"), "{}", out(&o)); + + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/crates/mtgc-server/tests/sync_e2e.rs b/crates/mtgc-server/tests/sync_e2e.rs new file mode 100644 index 0000000..fb49b45 --- /dev/null +++ b/crates/mtgc-server/tests/sync_e2e.rs @@ -0,0 +1,161 @@ +//! The app's sync client against the real server over HTTP: the wire protocol +//! end to end, with two devices that each keep a SQLite working copy. + +use std::path::{Path, PathBuf}; + +use mtgc_core::sync::account::{redeem_code, server_info, start_url, Pkce}; +use mtgc_core::sync::remote::{sync_cycle, HttpRemote, SyncBase, UNAUTHORIZED}; +use mtgc_core::sync::{apply_doc, merge_into, read_doc, CardEntry}; +use mtgc_server::auth::local::hash_password; +use mtgc_server::{db, router, AppState, Config}; + +/// Start a server on a free port and return its URL. It runs on its own thread +/// for the rest of the test process. +fn spawn_server() -> (String, AppState) { + let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", std_listener.local_addr().unwrap()); + let public = url.clone(); + let config = Config::from_lookup(move |k| (k == "MTGC_PUBLIC_URL").then(|| public.clone())).unwrap(); + let state = AppState::new(config, db::open_in_memory().unwrap()).unwrap(); + let app = router(state.clone()); + std_listener.set_nonblocking(true).unwrap(); + std::thread::spawn(move || { + tokio::runtime::Runtime::new().unwrap().block_on(async move { + let listener = tokio::net::TcpListener::from_std(std_listener).unwrap(); + axum::serve(listener, app).await.unwrap(); + }); + }); + (url, state) +} + +/// Sign in the way the app does, playing the browser's part by hand. +fn sign_in(server: &str, user: &str, password: &str) -> mtgc_core::sync::account::SignedIn { + let pkce = Pkce::new(); + let start = start_url(server, "http://127.0.0.1:9/callback", &pkce, "st", "Test device"); + + let page = ureq::get(&start).call().unwrap(); + let cookie = page.header("set-cookie").unwrap().split(';').next().unwrap().to_string(); + let pending = cookie.strip_prefix("mtgc_login=").unwrap().to_string(); + + let done = ureq::post(&format!("{server}/auth/password")) + .set("Cookie", &cookie) + .send_form(&[("pending", &pending), ("username", user), ("password", password)]) + .unwrap() + .into_string() + .unwrap(); + let start = done.find("code=").expect("signed in") + 5; + let code = &done[start..start + done[start..].find('&').unwrap()]; + + redeem_code(server, code, &pkce.verifier).unwrap() +} + +struct Device { + db: PathBuf, + base: SyncBase, + remote: HttpRemote, +} + +impl Device { + fn new(dir: &Path, name: &str, server: &str, token: &str) -> Self { + Self { + db: dir.join(format!("{name}.db")), + base: SyncBase::default(), + remote: HttpRemote::new(server, token), + } + } + + fn sync(&mut self) -> mtgc_core::Result { + let path = self.db.clone(); + sync_cycle(&self.remote, &mut self.base, |base, theirs| { + let conn = mtgc_core::db::open_library(&path)?; + merge_into(&conn, base, theirs) + }) + } + + fn set(&self, id: &str, qty: i64) { + let conn = mtgc_core::db::open_library(&self.db).unwrap(); + let mut doc = read_doc(&conn).unwrap(); + doc.cards.insert(id.into(), CardEntry { quantity: qty, location: String::new() }); + apply_doc(&conn, &doc).unwrap(); + } + + fn qty(&self, id: &str) -> Option { + let conn = mtgc_core::db::open_library(&self.db).unwrap(); + read_doc(&conn).unwrap().cards.get(id).map(|c| c.quantity) + } +} + +#[test] +fn two_devices_converge_through_the_real_server() { + let (server, state) = spawn_server(); + let hash = hash_password("correct horse battery").unwrap(); + db::create_local_user(&state.db(), "alice", "Alice", &hash).unwrap(); + let dir = std::env::temp_dir().join(format!("mtgc-e2e-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let info = server_info(&server).unwrap(); + assert!(info.local_auth); + + let phone_login = sign_in(&server, "alice", "correct horse battery"); + let desktop_login = sign_in(&server, "alice", "correct horse battery"); + assert_eq!(phone_login.user.id, desktop_login.user.id, "same account, same id"); + assert_eq!(phone_login.user.method, "local"); + + let mut phone = Device::new(&dir, "phone", &server, &phone_login.token); + let mut desktop = Device::new(&dir, "desktop", &server, &desktop_login.token); + + phone.set("a", 1); + phone.set("b", 1); + assert!(phone.sync().unwrap().published); + desktop.sync().unwrap(); + assert_eq!(desktop.qty("a"), Some(1)); + + // Offline edits on both, to different cards. + phone.set("a", 3); + desktop.set("b", 5); + phone.sync().unwrap(); + desktop.sync().unwrap(); + phone.sync().unwrap(); + + for d in [&phone, &desktop] { + assert_eq!(d.qty("a"), Some(3)); + assert_eq!(d.qty("b"), Some(5)); + } + assert!(!phone.sync().unwrap().published, "settled: nothing left to send"); + + // Revoking the phone from the desktop signs it out. + let me = desktop.remote.me().unwrap(); + let phone_id = me.devices.iter().find(|d| !d.current).unwrap().id; + desktop.remote.revoke_device(phone_id).unwrap(); + let err = phone.sync().unwrap_err(); + assert_eq!(err.to_string(), UNAUTHORIZED); + + desktop.remote.logout().unwrap(); + assert!(desktop.sync().is_err()); + + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn the_app_and_server_agree_on_pkce() { + let p = Pkce::new(); + assert_eq!(mtgc_server::auth::pkce_challenge(&p.verifier), p.challenge); +} + +#[test] +fn an_address_that_is_not_an_mtgc_server_is_rejected() { + // Something that answers HTTP but isn't us. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + use std::io::{Read, Write}; + if let Ok((mut s, _)) = listener.accept() { + let mut buf = [0u8; 1024]; + let _ = s.read(&mut buf); + let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello"); + } + }); + let err = server_info(&format!("http://{addr}")).unwrap_err(); + assert!(err.to_string().contains("doesn't look like an mtgc sync server"), "{err}"); +} diff --git a/package.json b/package.json index 943135b..95f3478 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "sync-server:build": "scripts/sync-server-image.sh build", + "sync-server:push": "scripts/sync-server-image.sh push" }, "dependencies": { "@tauri-apps/api": "^2", diff --git a/scripts/sync-server-image.sh b/scripts/sync-server-image.sh new file mode 100755 index 0000000..1e25ac6 --- /dev/null +++ b/scripts/sync-server-image.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# +# sync-server-image.sh — build or push the mtgc sync server's container image. +# +# The image is tagged ${DOCKER_REGISTRY}/mtgc/sync-server:${IMAGE_TAG}. +# +# Usage: +# DOCKER_REGISTRY=registry.example.com ./scripts/sync-server-image.sh build +# DOCKER_REGISTRY=registry.example.com ./scripts/sync-server-image.sh push +# +# Or through npm: `npm run sync-server:build` / `npm run sync-server:push`. +# +# Environment: +# DOCKER_REGISTRY (required) registry host, optionally with a namespace path +# IMAGE_TAG tag to build/push (default: latest) +# CONTAINER_ENGINE docker or podman (default: docker if installed, else podman) +# +# `push` pushes what `build` produced; it doesn't rebuild. Log in to the +# registry first (`docker login "$DOCKER_REGISTRY"`). +# +set -euo pipefail + +usage() { + sed -n '3,20p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +[ $# -eq 1 ] || usage 1 +case "$1" in + build | push) action="$1" ;; + -h | --help) usage ;; + *) usage 1 ;; +esac + +if [ -z "${DOCKER_REGISTRY:-}" ]; then + echo "sync-server-image: set DOCKER_REGISTRY (e.g. registry.example.com)" >&2 + exit 1 +fi + +engine="${CONTAINER_ENGINE:-}" +if [ -z "$engine" ]; then + if command -v docker >/dev/null 2>&1; then + engine=docker + elif command -v podman >/dev/null 2>&1; then + engine=podman + else + echo "sync-server-image: neither docker nor podman is installed" >&2 + exit 1 + fi +fi + +image="${DOCKER_REGISTRY%/}/mtgc/sync-server:${IMAGE_TAG:-latest}" + +# The Dockerfile builds from the workspace root, so run from there whatever +# directory this was invoked in. +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +case "$action" in + build) + echo "==> building $image" + "$engine" build -f crates/mtgc-server/Dockerfile -t "$image" . + ;; + push) + echo "==> pushing $image" + "$engine" push "$image" + ;; +esac diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 83f978a..dde0ee8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,3 +20,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" mtgc-core = { path = "../crates/mtgc-core" } tauri-plugin-opener = "2.5.4" +url = "2" + +[target.'cfg(target_os = "android")'.dependencies] +# Delivers sync sign-ins back to the app through the `mtgc://` scheme. +tauri-plugin-deep-link = "2" diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 0f54159..5498d35 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -2,9 +2,10 @@ - + diff --git a/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/SyncPlugin.kt b/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/SyncPlugin.kt deleted file mode 100644 index 5d979e6..0000000 --- a/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/SyncPlugin.kt +++ /dev/null @@ -1,295 +0,0 @@ -package com.garrettmills.mtgc - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.database.Cursor -import android.net.Uri -import android.provider.DocumentsContract -import androidx.activity.result.ActivityResult -import app.tauri.annotation.ActivityCallback -import app.tauri.annotation.Command -import app.tauri.annotation.InvokeArg -import app.tauri.annotation.TauriPlugin -import app.tauri.plugin.Invoke -import app.tauri.plugin.JSArray -import app.tauri.plugin.JSObject -import app.tauri.plugin.Plugin -import java.io.File - -@InvokeArg -class FileArgs { - /** File name inside the chosen folder, e.g. "library.json". */ - lateinit var name: String - - /** App-private staging path the bytes are copied to/from. */ - lateinit var path: String -} - -@InvokeArg -class NameArgs { - lateinit var name: String -} - -/** - * Bridges the Storage Access Framework to Rust for library sync. - * - * The user picks one folder through the system picker; we take a *persistable* - * URI permission on it so the grant survives restarts. That folder is a real - * directory in shared storage (e.g. /storage/emulated/0/mtgc), so a sync tool - * like Syncthing can see it, but this app reaches it only through SAF and - * therefore needs no storage permission in the manifest. - * - * SAF hands back `content://` URIs, which SQLite cannot open — so nothing here - * exposes a URI to Rust. Instead every operation copies bytes between the - * chosen folder and an app-private staging path that Rust picks, and Rust only - * ever does plain file I/O on its own private storage. That is sufficient - * because the synced copy is never opened as a database; it is only ever - * written whole or read whole. - */ -@TauriPlugin -class SyncPlugin(private val activity: Activity) : Plugin(activity) { - companion object { - private const val PREFS = "mtgc-sync" - private const val KEY_TREE_URI = "tree-uri" - - /** Generic type: a SQLite file has no useful registered MIME type. */ - private const val MIME = "application/octet-stream" - - private const val NO_FOLDER = - "No synced folder is set up yet — choose one first." - } - - private fun prefs() = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - - /** - * The chosen folder, but only when the persisted grant is still held. - * - * A saved URI isn't proof of access: the user can revoke it in system - * settings, and clearing app data drops it. Checking the live grant list - * keeps us from failing later with an opaque SecurityException. - */ - private fun grantedTree(): Uri? { - val saved = prefs().getString(KEY_TREE_URI, null) ?: return null - val uri = Uri.parse(saved) - val held = activity.contentResolver.persistedUriPermissions.any { - it.uri == uri && it.isReadPermission && it.isWritePermission - } - return if (held) uri else null - } - - /** Best-effort human-readable name for the folder, for display in the UI. */ - private fun label(uri: Uri): String = try { - val docId = DocumentsContract.getTreeDocumentId(uri) - val parts = docId.split(":", limit = 2) - if (parts.size == 2 && parts[0].equals("primary", ignoreCase = true)) { - "Internal storage/${parts[1]}" - } else { - docId - } - } catch (_: Exception) { - uri.toString() - } - - private fun statusObject(): JSObject { - val saved = prefs().getString(KEY_TREE_URI, null) - val granted = grantedTree() - return JSObject().apply { - // "chosen but not granted" is a distinct, recoverable state worth - // telling the user about — it means re-pick, not first-time setup. - put("chosen", saved != null) - put("granted", granted != null) - put("label", saved?.let { label(Uri.parse(it)) }) - } - } - - @Command - fun status(invoke: Invoke) { - invoke.resolve(statusObject()) - } - - @Command - fun pickFolder(invoke: Invoke) { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { - addFlags( - Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - ) - } - startActivityForResult(invoke, intent, "folderPicked") - } - - @ActivityCallback - fun folderPicked(invoke: Invoke, result: ActivityResult) { - val uri = result.data?.data - if (result.resultCode != Activity.RESULT_OK || uri == null) { - // Backing out of the picker is a normal choice, not a failure. - invoke.resolve(statusObject().apply { put("cancelled", true) }) - return - } - try { - // Without this the grant dies with the activity. - activity.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - } catch (e: Exception) { - invoke.reject("Couldn't keep access to that folder: ${e.message}") - return - } - prefs().edit().putString(KEY_TREE_URI, uri.toString()).apply() - invoke.resolve(statusObject().apply { put("cancelled", false) }) - } - - @Command - fun forgetFolder(invoke: Invoke) { - prefs().getString(KEY_TREE_URI, null)?.let { - try { - activity.contentResolver.releasePersistableUriPermission( - Uri.parse(it), - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - } catch (_: Exception) { - // Already gone; clearing our own record below is what matters. - } - } - prefs().edit().remove(KEY_TREE_URI).apply() - invoke.resolve(statusObject()) - } - - /** Locate a direct child of the chosen folder by display name. */ - private fun findChild(tree: Uri, name: String): Uri? { - val children = DocumentsContract.buildChildDocumentsUriUsingTree( - tree, - DocumentsContract.getTreeDocumentId(tree) - ) - val projection = arrayOf( - DocumentsContract.Document.COLUMN_DOCUMENT_ID, - DocumentsContract.Document.COLUMN_DISPLAY_NAME - ) - activity.contentResolver.query(children, projection, null, null, null) - ?.use { c: Cursor -> - while (c.moveToNext()) { - if (c.getString(1) == name) { - return DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0)) - } - } - } - return null - } - - /** - * Names of the files directly inside the chosen folder. Rust uses this to - * discover the other devices' snapshots; which names matter is entirely its - * business, so everything is returned unfiltered. - */ - @Command - fun list(invoke: Invoke) { - val tree = grantedTree() ?: return invoke.reject(NO_FOLDER) - try { - val names = JSArray() - val children = DocumentsContract.buildChildDocumentsUriUsingTree( - tree, - DocumentsContract.getTreeDocumentId(tree) - ) - val projection = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME) - activity.contentResolver.query(children, projection, null, null, null) - ?.use { c: Cursor -> - while (c.moveToNext()) { - c.getString(0)?.let { names.put(it) } - } - } - invoke.resolve(JSObject().apply { put("names", names) }) - } catch (e: Exception) { - invoke.reject("Couldn't list the synced folder: ${e.message}") - } - } - - /** - * Remove a file from the chosen folder. - * - * Used only to clear away conflict copies once their contents have been - * merged and republished — never to delete the library document itself. - */ - @Command - fun deleteFile(invoke: Invoke) { - val args = invoke.parseArgs(NameArgs::class.java) - val tree = grantedTree() ?: return invoke.reject(NO_FOLDER) - val doc = findChild(tree, args.name) - if (doc == null) { - invoke.resolve(JSObject().apply { put("removed", false) }) - return - } - try { - val removed = DocumentsContract.deleteDocument(activity.contentResolver, doc) - invoke.resolve(JSObject().apply { put("removed", removed) }) - } catch (e: Exception) { - invoke.reject("Couldn't remove ${args.name}: ${e.message}") - } - } - - /** Copy `name` out of the chosen folder into the app-private `path`. */ - @Command - fun readInto(invoke: Invoke) { - val args = invoke.parseArgs(FileArgs::class.java) - val tree = grantedTree() ?: return invoke.reject(NO_FOLDER) - val doc = findChild(tree, args.name) - if (doc == null) { - // A missing file is an expected state (nothing synced here yet), not an - // error — the caller decides what to do about it. - invoke.resolve(JSObject().apply { - put("found", false) - put("bytes", 0L) - }) - return - } - try { - val dest = File(args.path) - dest.parentFile?.mkdirs() - val copied = activity.contentResolver.openInputStream(doc)!!.use { input -> - dest.outputStream().use { out -> input.copyTo(out) } - } - invoke.resolve(JSObject().apply { - put("found", true) - put("bytes", copied) - }) - } catch (e: Exception) { - invoke.reject("Couldn't read ${args.name} from the synced folder: ${e.message}") - } - } - - /** Copy the app-private `path` into the chosen folder as `name`. */ - @Command - fun writeFrom(invoke: Invoke) { - val args = invoke.parseArgs(FileArgs::class.java) - val tree = grantedTree() ?: return invoke.reject(NO_FOLDER) - val src = File(args.path) - if (!src.isFile) return invoke.reject("Nothing to upload at ${args.path}") - try { - // Overwrite the existing document when there is one, rather than - // delete-and-recreate: a sync tool then sees one modified file instead - // of a removal followed by an unrelated addition. - val doc = findChild(tree, args.name) - ?: DocumentsContract.createDocument( - activity.contentResolver, - DocumentsContract.buildDocumentUriUsingTree( - tree, - DocumentsContract.getTreeDocumentId(tree) - ), - MIME, - args.name - ) - ?: return invoke.reject("Couldn't create ${args.name} in the synced folder") - // "wt" truncates first. Plain "w" leaves any trailing bytes of a longer - // previous file in place, which would silently corrupt a database that - // got smaller. - val copied = activity.contentResolver.openOutputStream(doc, "wt")!!.use { out -> - src.inputStream().use { input -> input.copyTo(out) } - } - invoke.resolve(JSObject().apply { put("bytes", copied) }) - } catch (e: Exception) { - invoke.reject("Couldn't write ${args.name} to the synced folder: ${e.message}") - } - } -} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f712c09..9a0028b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -20,7 +20,8 @@ type Res = Result; /// High-level status for the top bar / first-run gating. #[derive(Serialize)] pub struct Status { - pub library_path: String, + /// Where the library syncs: the account and server, or this device only. + pub library_label: String, pub reference_path: String, pub reference_ready: bool, pub card_count: usize, @@ -28,13 +29,27 @@ pub struct Status { /// when "Update card data" last completed. `None` if unavailable. pub reference_updated_at: Option, /// Whether this is a mobile build. The UI uses it to hide affordances that - /// only make sense with an arbitrary-path filesystem (choosing where - /// library.db lives) — see `set_library_path`. Reported from the build + /// only make sense with an arbitrary-path filesystem. Reported from the build /// target rather than sniffed from the user agent so it can't disagree with /// what the backend will actually allow. pub is_mobile: bool, } +/// Where the library syncs, for the top bar — never the internal working +/// database, which is an implementation detail the user never sees. +fn library_label(s: &AppState) -> String { + match s.sync_account() { + Some(a) if a.token.is_some() => { + let host = url::Url::parse(&a.server_url) + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + .unwrap_or_else(|| a.server_url.clone()); + format!("{} @ {host}", a.user.username) + } + _ => "This device only (not syncing)".to_string(), + } +} + fn status_of(s: &AppState) -> Status { let reference_updated_at = std::fs::metadata(&s.reference_path) .and_then(|m| m.modified()) @@ -42,11 +57,7 @@ fn status_of(s: &AppState) -> Status { .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64); Status { - // Where the library document lives, not the internal working database — - // that one is an implementation detail the user never sees. Always - // populated: with nothing chosen it's the default in private storage, - // so the path bar never goes blank. - library_path: s.effective_document_path().display().to_string(), + library_label: library_label(s), reference_path: s.reference_path.display().to_string(), reference_ready: s.catalog.is_some(), card_count: s.index.as_ref().map(|i| i.len()).unwrap_or(0), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a808e83..63d85ef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -33,26 +33,14 @@ pub(crate) fn logged(what: &str, result: Result) -> Result tauri::plugin::TauriPlugin { - tauri::plugin::Builder::new("mtgc-sync") - .setup(|app, api| { - let handle = api.register_android_plugin("com.garrettmills.mtgc", "SyncPlugin")?; - app.manage(sync::SyncBridge(handle)); - Ok(()) - }) - .build() -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = tauri::Builder::default().plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()); + // Android gets sync sign-ins back through the `mtgc://` scheme; desktop + // uses a loopback port instead and needs no scheme registered. #[cfg(target_os = "android")] - let builder = builder.plugin(sync_plugin()); + let builder = builder.plugin(tauri_plugin_deep_link::init()); builder .setup(|app| { @@ -63,6 +51,15 @@ pub fn run() { std::fs::create_dir_all(&data_dir).ok(); mlog!("data dir: {}", data_dir.display()); app.manage(Mutex::new(AppState::new(data_dir))); + #[cfg(target_os = "android")] + { + use tauri_plugin_deep_link::DeepLinkExt; + app.deep_link().on_open_url(|event| { + for url in event.urls() { + sync::deliver_callback(url.as_str()); + } + }); + } Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -71,13 +68,15 @@ pub fn run() { commands::cancel_update_card_data, commands::import_reference_db, sync::sync_status, - sync::sync_pick_folder, - sync::sync_set_document, - sync::sync_list_documents, - sync::sync_set_document_name, - sync::sync_forget_document, + sync::sync_sign_in, + sync::sync_cancel_sign_in, + sync::sync_sign_out, sync::sync_now, - sync::convert_legacy_library, + sync::sync_connect, + sync::sync_devices, + sync::sync_revoke_device, + sync::import_library_file, + sync::export_library_document, news::get_news, news::get_set_cards, commands::search_cards, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 8872c52..8f52fb4 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,36 +1,50 @@ -//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths. +//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths and +//! the sync account. //! -//! The user's library is a JSON *document* in a folder they sync. The SQLite -//! database behind it is an internal working copy under the app data dir that -//! they never see, name, or move — it can always be rebuilt by merging the -//! document into an empty one (see [`mtgc_core::sync`]). +//! The user's library lives in an internal SQLite working copy under the app +//! data dir, which works fully offline. When the device is signed in to a sync +//! server, that copy is reconciled with the server's whenever it can be reached +//! (see `sync.rs` and [`mtgc_core::sync::remote`]). use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use mtgc_core::sync::account::AccountUser; use mtgc_core::{Catalog, NameIndex}; use serde::{Deserialize, Serialize}; /// Small app-shell preferences persisted across restarts, distinct from the /// user's library data. +/// +/// Fields from older builds (`document_path`, `document_name`, from when the +/// library was a file in a synced folder) are simply ignored when read. #[derive(Default, Serialize, Deserialize, Clone)] struct PersistedConfig { - /// The library document the user chose. Desktop only — Android reaches its - /// folder through a SAF grant held on the Java side rather than by path, - /// so there is no path to persist there (see `sync.rs`). - #[serde(default)] - document_path: Option, /// A pre-document `library.db` inherited from an older build, kept until /// it has been converted so the app can offer to do that once. #[serde(default)] legacy_library_path: Option, - /// Which file inside the granted folder holds the library. Android only: - /// there the user grants a *folder* (SAF has no path to hand back), so the - /// file within it has to be chosen separately. Defaults to "library.json" - /// for a fresh setup, but must be settable — a library named anything else - /// would otherwise never be found, and the phone would quietly create an - /// empty second one beside it. + /// The sync server this device is (or was last) signed in to. #[serde(default)] - document_name: Option, + sync: Option, +} + +/// This device's sync server account. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct SyncAccount { + pub server_url: String, + /// This device's token. `None` once signed out, or once the server stopped + /// accepting it; the rest is kept so signing back in can be offered with + /// the address already filled in. + #[serde(default)] + pub token: Option, + pub user: AccountUser, + #[serde(default)] + pub device_id: i64, + /// Unix seconds of the last sync that completed. + #[serde(default)] + pub last_synced_at: Option, } fn config_path(data_dir: &Path) -> PathBuf { @@ -42,11 +56,7 @@ fn load_config(data_dir: &Path) -> PersistedConfig { return PersistedConfig::default(); }; // Every field defaults, so an older or partial file parses fine and simply - // leaves the unknown settings unset. Deliberately *not* keyed on which - // fields happen to be populated: an Android install has neither a - // `document_path` (its folder is a SAF grant) nor a legacy library, so a - // "does this look empty?" test threw away its `document_name` on every - // launch and silently reverted the chosen library file. + // leaves the unknown settings unset. let mut cfg: PersistedConfig = serde_json::from_str(&text).unwrap_or_default(); // Builds before the document format stored `library_path` pointing at a @@ -66,19 +76,43 @@ fn load_config(data_dir: &Path) -> PersistedConfig { } fn save_config(data_dir: &Path, cfg: &PersistedConfig) { - if let Ok(s) = serde_json::to_string_pretty(cfg) { - let _ = std::fs::write(config_path(data_dir), s); + let Ok(text) = serde_json::to_string_pretty(cfg) else { + return; + }; + // Written beside and renamed into place, so a crash mid-write can't leave + // a truncated config that silently signs the device out. + let path = config_path(data_dir); + let tmp = path.with_extension("json.tmp"); + if let Err(e) = write_private(&tmp, &text).and_then(|_| std::fs::rename(&tmp, &path)) { + crate::mlog!("save_config: {e}"); + let _ = std::fs::remove_file(&tmp); } } +/// Write a file only this user can read: the config holds the device's sync +/// token. (App data is already private on Android; this covers desktop.) +fn write_private(path: &Path, text: &str) -> std::io::Result<()> { + use std::io::Write; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts.open(path)?; + f.write_all(text.as_bytes())?; + f.sync_all() +} + /// Managed application state, guarded by a `Mutex` in Tauri's state store. pub struct AppState { data_dir: PathBuf, /// Held whole so that writing one preference can't drop the others — the /// setters below persist this rather than reconstructing a fresh config. config: PersistedConfig, - /// Internal SQLite working copy. Derived from the document; never chosen - /// by, shown to, or moved by the user. + /// Internal SQLite working copy: the library itself, on this device. Never + /// chosen by, shown to, or moved by the user. pub working_db_path: PathBuf, pub reference_path: PathBuf, pub catalog: Option, @@ -86,10 +120,20 @@ pub struct AppState { /// Set while `update_card_data` is running; `cancel_update_card_data` /// flips it to abort the in-progress ingest. pub ingest_cancel: Option, - /// Set while a sync is in flight. Sync fires automatically on launch and on - /// every foreground/background transition, so overlapping calls are easy to - /// provoke; a second one would merge the same document concurrently. + /// Set while a sync is in flight. Sync fires automatically on launch, on + /// every foreground/background transition, and on reconnecting, so + /// overlapping calls are easy to provoke; a second one would only repeat + /// the first one's work. pub sync_running: bool, + /// Why the last sync failed, until one succeeds. Not persisted: after a + /// restart the next sync says soon enough. + pub sync_error: Option, + /// The first sync with this account found a library both here and on the + /// server, and only the user can say whether to combine them or take the + /// server's. Syncing waits until they do. + pub sync_needs_choice: bool, + /// Set while a sign-in waits on the browser; flipping it abandons the wait. + pub sign_in_cancel: Option>, } impl AppState { @@ -104,10 +148,13 @@ impl AppState { index: None, ingest_cancel: None, sync_running: false, + sync_error: None, + sync_needs_choice: false, + sign_in_cancel: None, }; crate::mlog!( - "AppState::new: document={:?} working={} reference={} (exists={})", - s.config.document_path, + "AppState::new: sync={:?} working={} reference={} (exists={})", + s.config.sync.as_ref().map(|a| (&a.server_url, &a.user.username, a.token.is_some())), s.working_db_path.display(), s.reference_path.display(), s.reference_path.exists() @@ -123,48 +170,26 @@ impl AppState { s } - /// The library document the user explicitly chose, if they have. - pub fn document_path(&self) -> Option<&Path> { - self.config.document_path.as_deref() + pub fn data_dir(&self) -> &Path { + &self.data_dir } - /// Where the library document actually lives right now. - /// - /// Falls back to app-private storage so there is *always* a document. The - /// alternative — no document until the user picks one — meant edits piled - /// up in the working copy with nowhere to be saved, and choosing a - /// destination later would discard them. Picking a location is a - /// preference, not a precondition for using the app. - pub fn effective_document_path(&self) -> PathBuf { - self.config - .document_path - .clone() - .unwrap_or_else(|| self.default_document_path()) + /// The sync account, signed in or not. + pub fn sync_account(&self) -> Option<&SyncAccount> { + self.config.sync.as_ref() } - pub fn default_document_path(&self) -> PathBuf { - self.data_dir.join("library.json") - } - - /// Point at (or, with `None`, forget) the library document. - pub fn set_document_path(&mut self, path: Option) { - self.config.document_path = path; + pub fn set_sync_account(&mut self, account: Option) { + self.config.sync = account; save_config(&self.data_dir, &self.config); } - /// The library file within the granted folder. - /// - /// Android-only: desktop names the whole path at once, so these are dead - /// there rather than genuinely unused. - #[cfg_attr(not(target_os = "android"), allow(dead_code))] - pub fn document_name(&self) -> Option<&str> { - self.config.document_name.as_deref() - } - - #[cfg_attr(not(target_os = "android"), allow(dead_code))] - pub fn set_document_name(&mut self, name: Option) { - self.config.document_name = name; - save_config(&self.data_dir, &self.config); + /// Change the sync account in place and persist it. A no-op without one. + pub fn update_sync_account(&mut self, f: impl FnOnce(&mut SyncAccount)) { + if let Some(a) = self.config.sync.as_mut() { + f(a); + save_config(&self.data_dir, &self.config); + } } /// A pre-document `library.db` still waiting to be converted. @@ -227,39 +252,63 @@ mod tests { dir } - #[test] - fn a_document_name_alone_survives_a_reload() { - // An Android install has no document_path (its folder is a SAF grant) - // and no legacy library, so this config's *only* meaningful field is - // the chosen library file. A loader that decided emptiness meant "must - // be an old config" discarded it on every launch, silently reverting - // the user's choice back to library.json. - let dir = scratch("android-shape"); - std::fs::write( - config_path(&dir), - r#"{"document_path":null,"legacy_library_path":null,"document_name":"decks-v1.json"}"#, - ) - .unwrap(); - - let cfg = load_config(&dir); - assert_eq!(cfg.document_name.as_deref(), Some("decks-v1.json")); - std::fs::remove_dir_all(&dir).unwrap(); + fn account() -> SyncAccount { + SyncAccount { + server_url: "https://mtgc.example".into(), + token: Some("mtgc_secret".into()), + user: AccountUser { + id: "u1".into(), + username: "alice".into(), + display_name: "Alice".into(), + email: "alice@example.com".into(), + method: "local".into(), + }, + device_id: 7, + last_synced_at: Some(1_700_000_000), + } } #[test] fn every_setting_round_trips() { let dir = scratch("round-trip"); let cfg = PersistedConfig { - document_path: Some(PathBuf::from("/tmp/lib.json")), legacy_library_path: Some(PathBuf::from("/tmp/old.db")), - document_name: Some("mine.json".into()), + sync: Some(account()), }; save_config(&dir, &cfg); let back = load_config(&dir); - assert_eq!(back.document_path, cfg.document_path); assert_eq!(back.legacy_library_path, cfg.legacy_library_path); - assert_eq!(back.document_name, cfg.document_name); + assert_eq!(back.sync, cfg.sync); + assert!(!dir.join("config.json.tmp").exists()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn the_config_holding_the_token_is_private() { + use std::os::unix::fs::PermissionsExt; + let dir = scratch("private"); + save_config(&dir, &PersistedConfig { legacy_library_path: None, sync: Some(account()) }); + let mode = std::fs::metadata(config_path(&dir)).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_folder_sync_config_still_loads() { + // What the previous, folder-based build wrote. Its settings no longer + // mean anything, but the file must not fail to load over them. + let dir = scratch("folder-era"); + std::fs::write( + config_path(&dir), + r#"{"document_path":"/home/u/Sync/library.json","legacy_library_path":"/tmp/old.db","document_name":"decks.json"}"#, + ) + .unwrap(); + + let cfg = load_config(&dir); + assert_eq!(cfg.legacy_library_path, Some(PathBuf::from("/tmp/old.db"))); + assert!(cfg.sync.is_none()); std::fs::remove_dir_all(&dir).unwrap(); } @@ -277,7 +326,6 @@ mod tests { cfg.legacy_library_path, Some(PathBuf::from("/home/u/Seafile/Decks-v1.db")) ); - assert!(cfg.document_path.is_none(), "a .db is never the document"); std::fs::remove_dir_all(&dir).unwrap(); } @@ -293,9 +341,9 @@ mod tests { #[test] fn an_unreadable_or_corrupt_config_falls_back_to_defaults() { let dir = scratch("corrupt"); - assert!(load_config(&dir).document_name.is_none(), "missing file"); + assert!(load_config(&dir).sync.is_none(), "missing file"); std::fs::write(config_path(&dir), "{not json").unwrap(); - assert!(load_config(&dir).document_name.is_none(), "truncated file"); + assert!(load_config(&dir).sync.is_none(), "truncated file"); std::fs::remove_dir_all(&dir).unwrap(); } } diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 115fee6..b9280c7 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -1,835 +1,805 @@ -//! Reading and writing the user's library document, wherever they keep it. +//! Library sync with an mtgc sync server: signing in, and running the cycle. //! -//! The reconciliation itself lives in [`mtgc_core::sync`] as a pure function. -//! This module is only the plumbing: getting the document in and out of a -//! folder that differs by platform, and running the cycle in the right order. +//! The reconciliation lives in [`mtgc_core::sync::remote`], as a cycle over a +//! [`Remote`](mtgc_core::sync::remote::Remote) that is tested without any +//! network. This module is the plumbing around it: where the merge base is +//! kept, holding the state lock while the working copy is rewritten (and only +//! then), and the platform-specific half of signing in. //! -//! Desktop points at an ordinary file. Android can't: the working database must -//! stay in app-private storage (SQLite can't open a `content://` URI), and the -//! user's folder is reachable only through the Storage Access Framework. So on -//! Android every transfer is staged through an app-private file that Rust owns -//! and the Kotlin side copies to or from — deliberately avoiding -//! `MANAGE_EXTERNAL_STORAGE`, which would hand the app the entire shared volume -//! just to reach one folder. [`Folder`] hides that split. +//! The library never depends on the server being reachable. The working copy +//! is the library on this device; every edit lands there, and a sync is just a +//! chance to exchange edits with the other devices. A failed sync loses +//! nothing and is retried on the next trigger. +//! +//! # Signing in +//! +//! The browser does the signing in, on the server's own pages, and hands back +//! a single-use code that this app redeems with its PKCE verifier. Getting the +//! code back differs by platform: +//! +//! - desktop listens on a one-shot loopback port (RFC 8252 §7.3); +//! - Android registers the `mtgc://` scheme, and the deep-link plugin delivers +//! the callback to [`deliver_callback`]. use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use mtgc_core::sync::account::{self, AccountUser, DeviceInfo, Pkce}; +use mtgc_core::sync::remote::{ + first_sync_plan, sync_cycle, AccountBase, FirstSync, HttpRemote, SyncBase, UNAUTHORIZED, +}; use mtgc_core::sync::{self as core_sync, LibraryDoc, MergeReport}; use serde::Serialize; -#[cfg(target_os = "android")] -use serde::Deserialize; use tauri::{AppHandle, Manager}; -use crate::state::AppState; +use crate::state::{AppState, SyncAccount}; -type Db<'a> = tauri::State<'a, std::sync::Mutex>; +/// How long to wait for the user to finish signing in in the browser. +const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(10 * 60); -/// Fallback name when the user has granted a folder but not yet said which file -/// in it is their library — i.e. a fresh setup with nothing to adopt. -#[cfg(target_os = "android")] -const DEFAULT_DOC_NAME: &str = "library.json"; - -/// Where transfers are staged across the SAF boundary. Inside the app data dir, -/// so it inherits the same private-storage guarantees as the working copy. -/// Android-only: desktop reads and writes the document directly. -#[cfg(target_os = "android")] -fn staging_path(data_dir: &Path, name: &str) -> PathBuf { - data_dir.join("sync-staging").join(name) +fn app_state(app: &AppHandle) -> tauri::State<'_, Mutex> { + app.state::>() } +fn lock(app: &AppHandle) -> std::sync::MutexGuard<'_, AppState> { + // Every holder leaves the state consistent between statements, so a panic + // elsewhere is no reason to stop syncing. + app_state(app).inner().lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Run blocking work (network, SQLite) off the main thread. Tauri runs +/// non-async commands on the main thread, where a slow server would freeze +/// the whole UI. +async fn blocking( + f: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tauri::async_runtime::spawn_blocking(f) + .await + .map_err(|e| e.to_string())? +} + +// --- the merge base ----------------------------------------------------------- + +/// A new name rather than the folder-sync era's `sync-base.json`, whose +/// contents mean nothing to a server: an old file is simply never read. fn base_path(data_dir: &Path) -> PathBuf { - data_dir.join("sync-base.json") + data_dir.join("sync-server-base.json") } -/// State of the library document, as shown in the UI. +/// The stored base, if it belongs to this server and account. Missing, +/// unreadable, or someone else's all mean the same thing: no ancestor, which +/// makes the next sync a first sync. +fn read_base(data_dir: &Path, account: &SyncAccount) -> Option { + let text = std::fs::read_to_string(base_path(data_dir)).ok()?; + serde_json::from_str::(&text) + .ok()? + .for_account(&account.server_url, &account.user.id) +} + +fn write_base(data_dir: &Path, account: &SyncAccount, base: &SyncBase) { + let stored = AccountBase { + server_url: account.server_url.clone(), + user_id: account.user.id.clone(), + base: base.clone(), + }; + let path = base_path(data_dir); + let tmp = path.with_extension("json.tmp"); + let result = serde_json::to_string(&stored) + .map_err(|e| e.to_string()) + .and_then(|t| std::fs::write(&tmp, t).map_err(|e| e.to_string())) + .and_then(|_| std::fs::rename(&tmp, &path).map_err(|e| e.to_string())); + if let Err(e) = result { + // Not fatal: without a base the next sync is a first sync, which can + // ask a question but never deletes anything. + crate::mlog!("sync: couldn't save the merge base: {e}"); + } +} + +/// The working copy as a document. +fn local_doc(working: &Path) -> Result { + let conn = mtgc_core::db::open_library(working).map_err(|e| e.to_string())?; + core_sync::read_doc(&conn).map_err(|e| e.to_string()) +} + +// --- status ------------------------------------------------------------------- + #[derive(Serialize, Default)] pub struct SyncStatus { - /// Whether a document has been chosen at all. - pub chosen: bool, - /// Whether it's currently reachable. Distinct from `chosen`: an Android SAF - /// grant can be revoked and a desktop folder can be deleted or unmounted, - /// both of which need re-picking rather than first-time setup. - pub granted: bool, - pub label: Option, - /// True where the folder is chosen through the system picker (Android) - /// rather than by path — the one thing the UI branches on. - pub picker_based: bool, - /// A pre-document `library.db` waiting to be converted, if any. + pub signed_in: bool, + /// Kept after signing out, to offer signing back in. + pub server_url: Option, + pub account: Option, + pub last_synced_at: Option, + pub last_error: Option, + /// The first sync is waiting on the user: see [`sync_connect`]. + pub needs_choice: bool, + /// Edits made here that the server hasn't acknowledged yet. + pub pending_changes: bool, + /// A sign-in is waiting on the browser. + pub signing_in: bool, + /// A pre-document `library.db` waiting to be imported, if any. pub legacy_library: Option, - /// Which file in the granted folder is the library (Android). `None` on - /// desktop, where the whole path is chosen at once. - pub document_name: Option, } -#[cfg(target_os = "android")] -mod bridge { - use super::*; - - /// Handle on the Kotlin-side `SyncPlugin`, put into Tauri's state store - /// during plugin setup (see `lib.rs`). - pub struct SyncBridge(pub tauri::plugin::PluginHandle); - - #[derive(Serialize)] - struct FileArgs<'a> { - name: &'a str, - path: &'a str, - } - - #[derive(Serialize)] - struct NameArgs<'a> { - name: &'a str, - } - - #[derive(Deserialize)] - struct ReadResult { - found: bool, - } - - #[derive(Deserialize)] - struct ListResult { - #[serde(default)] - names: Vec, - } - - #[derive(Deserialize, Default)] - pub struct FolderStatus { - #[serde(default)] - pub chosen: bool, - #[serde(default)] - pub granted: bool, - #[serde(default)] - pub label: Option, - } - - fn call( - app: &AppHandle, - command: &str, - payload: impl Serialize, - ) -> Result { - let bridge = app - .try_state::() - .ok_or_else(|| "Sync plugin failed to load — see the logs.".to_string())?; - bridge - .0 - .run_mobile_plugin::(command, payload) - .map_err(|e| e.to_string()) - } - - pub fn status(app: &AppHandle) -> Result { - call(app, "status", ()) - } - - pub fn pick_folder(app: &AppHandle) -> Result { - call(app, "pickFolder", ()) - } - - pub fn forget_folder(app: &AppHandle) -> Result { - call(app, "forgetFolder", ()) - } - - pub fn list(app: &AppHandle) -> Result, String> { - Ok(call::(app, "list", ())?.names) - } - - pub fn read_into(app: &AppHandle, name: &str, path: &Path) -> Result { - let res: ReadResult = call( - app, - "readInto", - FileArgs { name, path: &path.display().to_string() }, - )?; - Ok(res.found) - } - - pub fn write_from(app: &AppHandle, name: &str, path: &Path) -> Result<(), String> { - let _: serde_json::Value = call( - app, - "writeFrom", - FileArgs { name, path: &path.display().to_string() }, - )?; - Ok(()) - } - - pub fn delete(app: &AppHandle, name: &str) -> Result<(), String> { - let _: serde_json::Value = call(app, "deleteFile", NameArgs { name })?; - Ok(()) - } -} - -#[cfg(target_os = "android")] -pub use bridge::SyncBridge; - -/// The folder as an ordinary directory — desktop's whole implementation. Free -/// functions so they stay callable, and testable, without a running app. -mod plain { - use std::path::Path; - - pub fn list(dir: &Path) -> Result, String> { - let entries = - std::fs::read_dir(dir).map_err(|e| format!("Can't read {}: {e}", dir.display()))?; - let mut names = Vec::new(); - for entry in entries.flatten() { - if entry.file_type().map(|t| t.is_file()).unwrap_or(false) { - names.push(entry.file_name().to_string_lossy().into_owned()); - } - } - Ok(names) - } - - pub fn read(dir: &Path, name: &str) -> Result, String> { - let path = dir.join(name); - if !path.is_file() { - return Ok(None); - } - std::fs::read_to_string(&path) - .map(Some) - .map_err(|e| format!("Can't read {}: {e}", path.display())) - } - - pub fn write(dir: &Path, name: &str, text: &str) -> Result<(), String> { - std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; - // Write beside the target and rename, so whatever is watching the - // folder never observes a half-written document. - let tmp = dir.join(format!("{name}.tmp")); - std::fs::write(&tmp, text) - .map_err(|e| format!("Can't write in {}: {e}", dir.display()))?; - std::fs::rename(&tmp, dir.join(name)).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - format!("Can't save {name} in {}: {e}", dir.display()) +fn status(app: &AppHandle) -> SyncStatus { + let s = lock(app); + let account = s.sync_account().cloned(); + let pending_changes = account + .as_ref() + .filter(|a| a.token.is_some()) + .map(|a| match read_base(s.data_dir(), a) { + Some(base) => local_doc(&s.working_db_path).map(|d| d != base.doc).unwrap_or(false), + // Never synced with this account: anything here is unsent. + None => local_doc(&s.working_db_path).map(|d| !d.is_empty()).unwrap_or(false), }) + .unwrap_or(false); + SyncStatus { + signed_in: account.as_ref().is_some_and(|a| a.token.is_some()), + server_url: account.as_ref().map(|a| a.server_url.clone()), + last_synced_at: account.as_ref().and_then(|a| a.last_synced_at), + account: account.map(|a| a.user), + last_error: s.sync_error.clone(), + needs_choice: s.sync_needs_choice, + pending_changes, + signing_in: s.sign_in_cancel.is_some(), + legacy_library: s.legacy_library_path().map(|p| p.display().to_string()), } - - pub fn delete(dir: &Path, name: &str) -> Result<(), String> { - std::fs::remove_file(dir.join(name)) - .map_err(|e| format!("Can't remove {name}: {e}")) - } -} - -/// Where the library document lives, however this platform reaches it. -struct Location { - folder: Folder, - doc_name: String, -} - -enum Folder { - #[cfg(target_os = "android")] - Picker, - Path(PathBuf), -} - -impl Location { - fn list(&self, #[allow(unused_variables)] app: &AppHandle) -> Result, String> { - match &self.folder { - #[cfg(target_os = "android")] - Folder::Picker => bridge::list(app), - Folder::Path(dir) => plain::list(dir), - } - } - - /// Read a document by name. `Ok(None)` means it isn't there — a normal - /// state (nothing published yet), not a failure. - fn read( - &self, - #[allow(unused_variables)] app: &AppHandle, - #[allow(unused_variables)] data_dir: &Path, - name: &str, - ) -> Result, String> { - match &self.folder { - #[cfg(target_os = "android")] - Folder::Picker => { - let staged = staging_path(data_dir, "incoming.json"); - if !bridge::read_into(app, name, &staged)? { - return Ok(None); - } - let text = std::fs::read_to_string(&staged).map_err(|e| e.to_string())?; - let _ = std::fs::remove_file(&staged); - Ok(Some(text)) - } - Folder::Path(dir) => plain::read(dir, name), - } - } - - fn write( - &self, - #[allow(unused_variables)] app: &AppHandle, - #[allow(unused_variables)] data_dir: &Path, - text: &str, - ) -> Result<(), String> { - match &self.folder { - #[cfg(target_os = "android")] - Folder::Picker => { - let staged = staging_path(data_dir, "outgoing.json"); - if let Some(parent) = staged.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - std::fs::write(&staged, text).map_err(|e| e.to_string())?; - let result = bridge::write_from(app, &self.doc_name, &staged); - let _ = std::fs::remove_file(&staged); - result - } - Folder::Path(dir) => plain::write(dir, &self.doc_name, text), - } - } - - fn delete(&self, #[allow(unused_variables)] app: &AppHandle, name: &str) -> Result<(), String> { - match &self.folder { - #[cfg(target_os = "android")] - Folder::Picker => bridge::delete(app, name), - Folder::Path(dir) => plain::delete(dir, name), - } - } - - /// Names of files that look like conflict copies of our document — what - /// sync tools leave behind when two devices publish while offline. - /// - /// Matched by shape (same stem, `.json`) rather than by any tool's naming - /// convention, so it works for Syncthing, Dropbox, Nextcloud and the rest - /// without knowing about any of them. - fn conflict_copies(&self, app: &AppHandle) -> Vec { - conflict_copies_of(&self.doc_name, self.list(app).unwrap_or_default()) - } -} - -/// Pick the conflict copies of `doc_name` out of a directory listing. -/// -/// Split out from [`Location::conflict_copies`] so the matching rule — the part -/// with all the edge cases — is testable without a running app. -fn conflict_copies_of(doc_name: &str, names: Vec) -> Vec { - let stem = doc_name.strip_suffix(".json").unwrap_or(doc_name); - let mut found: Vec = names - .into_iter() - .filter(|n| { - n != doc_name - && n.starts_with(stem) - && n.ends_with(".json") - // Our own atomic-write temporary, if a previous run died - // between writing and renaming. - && !n.ends_with(".json.tmp") - }) - .collect(); - found.sort(); - found -} - -/// Resolve where the document lives. -/// -/// Never fails for want of a choice: with nothing picked, the document sits in -/// app-private storage, so the library is always saved somewhere. Choosing a -/// synced location is a preference on top of that. -fn locate(#[allow(unused_variables)] app: &AppHandle, db: &Db) -> Result { - #[cfg(target_os = "android")] - { - let status = bridge::status(app)?; - if status.chosen && !status.granted { - // Silently falling back to private storage here would look like the - // library had been emptied, so this is worth surfacing. - return Err("Access to your library folder was lost — choose it again.".into()); - } - if status.granted { - return Ok(Location { - folder: Folder::Picker, - doc_name: db - .lock() - .unwrap() - .document_name() - .unwrap_or(DEFAULT_DOC_NAME) - .to_string(), - }); - } - } - let path = db.lock().unwrap().effective_document_path(); - let dir = path - .parent() - .ok_or_else(|| format!("{} has no folder.", path.display()))? - .to_path_buf(); - let doc_name = path - .file_name() - .ok_or_else(|| format!("{} isn't a file name.", path.display()))? - .to_string_lossy() - .into_owned(); - Ok(Location { folder: Folder::Path(dir), doc_name }) } #[tauri::command] -pub fn sync_status( - #[allow(unused_variables)] app: AppHandle, - db: Db, -) -> Result { - let legacy_library = db - .lock() - .unwrap() - .legacy_library_path() - .map(|p| p.display().to_string()); +pub async fn sync_status(app: AppHandle) -> Result { + blocking(move || Ok(status(&app))).await +} +// --- signing in ------------------------------------------------------------------- + +/// A name for this device in the account's device list. +fn default_device_name() -> String { #[cfg(target_os = "android")] { - let s = bridge::status(&app)?; - if s.chosen { - let name = db - .lock() - .unwrap() - .document_name() - .unwrap_or(DEFAULT_DOC_NAME) - .to_string(); - return Ok(SyncStatus { - chosen: true, - granted: s.granted, - // Show the folder *and* the file, since on Android they're - // chosen in two separate steps. - label: s.label.map(|l| format!("{l}/{name}")), - picker_based: true, - legacy_library, - document_name: Some(name), - }); + "Android".to_string() + } + #[cfg(not(target_os = "android"))] + { + let host = std::env::var("COMPUTERNAME") + .ok() + .or_else(|| std::fs::read_to_string("/etc/hostname").ok()) + .map(|h| h.trim().to_string()) + .filter(|h| !h.is_empty()); + match host { + Some(h) => h, + None => std::env::consts::OS.to_string(), } - // No folder picked: the library is in private storage and works, it - // just isn't synced anywhere yet. - return Ok(SyncStatus { - chosen: false, - granted: true, - label: Some( - db.lock() - .unwrap() - .effective_document_path() - .display() - .to_string(), - ), - picker_based: true, - legacy_library, - document_name: None, + } +} + +/// Sign in to a sync server through the browser, then sync. +/// +/// Resolves once the user finishes (or abandons) signing in. Any first-sync +/// question is reported through [`SyncStatus::needs_choice`]. +#[tauri::command] +pub async fn sync_sign_in( + app: AppHandle, + server_url: String, + device_name: Option, +) -> Result { + let server_url = account::normalize_server_url(&server_url); + let cancel = Arc::new(AtomicBool::new(false)); + { + let mut s = lock(&app); + if s.sign_in_cancel.is_some() { + return Err("A sign-in is already in progress.".into()); + } + s.sign_in_cancel = Some(cancel.clone()); + } + + let app2 = app.clone(); + let result = blocking(move || { + let device_name = device_name + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(default_device_name); + sign_in_flow(&app2, &server_url, &device_name, &cancel) + }) + .await; + lock(&app).sign_in_cancel = None; + + let signed_in = crate::logged("sync_sign_in", result)?; + { + let mut s = lock(&app); + let previous = s.sync_account().cloned(); + let switching = previous + .as_ref() + .is_some_and(|p| p.server_url != signed_in.0 || p.user.id != signed_in.1.user.id); + s.set_sync_account(Some(SyncAccount { + server_url: signed_in.0.clone(), + token: Some(signed_in.1.token), + user: signed_in.1.user, + device_id: signed_in.1.device_id, + last_synced_at: if switching { None } else { previous.and_then(|p| p.last_synced_at) }, + })); + s.sync_error = None; + s.sync_needs_choice = false; + } + + // Sync straight away so the library arrives (or the question is asked) + // without waiting for the next trigger. A failure here is recorded in the + // status rather than failing a sign-in that did succeed. + let _ = sync_now(app.clone()).await; + blocking(move || Ok(status(&app))).await +} + +/// Abandon a sign-in that's waiting on the browser. +#[tauri::command] +pub fn sync_cancel_sign_in(app: AppHandle) { + if let Some(c) = lock(&app).sign_in_cancel.as_ref() { + c.store(true, Ordering::SeqCst); + } +} + +/// Returns (server URL, the completed sign-in). +fn sign_in_flow( + app: &AppHandle, + server_url: &str, + device_name: &str, + cancel: &AtomicBool, +) -> Result<(String, account::SignedIn), String> { + let info = account::server_info(server_url).map_err(|e| e.to_string())?; + if !info.local_auth && info.oidc.is_none() { + return Err("That server has no way to sign in configured.".into()); + } + + let pkce = Pkce::new(); + let state = account::random_token(); + let callback = Callback::listen()?; + let url = account::start_url(server_url, &callback.redirect_uri(), &pkce, &state, device_name); + + use tauri_plugin_opener::OpenerExt; + app.opener() + .open_url(&url, None::<&str>) + .map_err(|e| format!("Couldn't open the browser: {e}"))?; + + let params = callback.wait(cancel, Instant::now() + SIGN_IN_TIMEOUT)?; + let get = |k: &str| params.iter().find(|(name, _)| name == k).map(|(_, v)| v.clone()); + if get("state").as_deref() != Some(state.as_str()) { + return Err("The sign-in response didn't match this sign-in. Try again.".into()); + } + if let Some(err) = get("error") { + return Err(match err.as_str() { + "access_denied" => "Sign-in was declined.".into(), + other => format!("Sign-in failed: {other}"), }); } - #[cfg(not(target_os = "android"))] - { - let s = db.lock().unwrap(); - let path = s.effective_document_path(); - Ok(SyncStatus { - chosen: s.document_path().is_some(), - // The folder can be gone — deleted, or on a drive that isn't - // mounted. The document itself needn't exist: sync creates it. - granted: path.parent().map(Path::is_dir).unwrap_or(false), - label: Some(path.display().to_string()), - picker_based: false, - legacy_library, - document_name: None, - }) + let code = get("code").ok_or("The sign-in response had no code. Try again.")?; + let signed_in = + account::redeem_code(server_url, &code, &pkce.verifier).map_err(|e| e.to_string())?; + crate::mlog!( + "sync_sign_in: signed in to {server_url} as {} ({})", + signed_in.user.username, + signed_in.user.method + ); + Ok((server_url.to_string(), signed_in)) +} + +type Params = Vec<(String, String)>; + +/// Where the browser hands the sign-in back. +#[cfg(not(target_os = "android"))] +struct Callback { + listener: std::net::TcpListener, +} + +#[cfg(not(target_os = "android"))] +impl Callback { + fn listen() -> Result { + // Port 0: any free port. The server accepts any loopback port, and a + // fixed one could already be taken. + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .map_err(|e| format!("Couldn't open a port for the sign-in to return to: {e}"))?; + listener.set_nonblocking(true).map_err(|e| e.to_string())?; + Ok(Self { listener }) + } + + fn redirect_uri(&self) -> String { + let port = self.listener.local_addr().map(|a| a.port()).unwrap_or(0); + format!("http://127.0.0.1:{port}/callback") + } + + fn wait(self, cancel: &AtomicBool, deadline: Instant) -> Result { + loop { + if cancel.load(Ordering::SeqCst) { + return Err("Sign-in cancelled.".into()); + } + if Instant::now() > deadline { + return Err("Sign-in timed out. Try again.".into()); + } + match self.listener.accept() { + Ok((stream, _)) => { + // Browsers also ask for /favicon.ico and the like; answer + // and keep waiting for the real callback. + if let Some(params) = loopback::handle(stream) { + return Ok(params); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(100)); + } + Err(e) => return Err(format!("Sign-in listener failed: {e}")), + } + } } } -/// Android only: grant access to a folder through the system picker. -#[tauri::command] -pub fn sync_pick_folder( - #[allow(unused_variables)] app: AppHandle, - db: Db, -) -> Result { - #[cfg(target_os = "android")] - { - crate::logged("sync_pick_folder", bridge::pick_folder(&app))?; - sync_status(app, db) - } - #[cfg(not(target_os = "android"))] - { - let _ = (app, db); - Err("This platform chooses the library file by path.".into()) - } -} +/// The desktop's one-request HTTP server, free-standing so it's testable. +#[cfg(not(target_os = "android"))] +mod loopback { + use std::io::{Read, Write}; + use std::net::TcpStream; + use std::time::Duration; -/// Desktop only: adopt a library document the frontend picked. -#[tauri::command] -pub fn sync_set_document( - app: AppHandle, - db: Db, - #[allow(unused_variables)] path: String, -) -> Result { - #[cfg(target_os = "android")] - { - let _ = (&app, &db, path); - Err("This platform chooses the library folder through the system picker.".into()) - } - #[cfg(not(target_os = "android"))] - { - let mut path = PathBuf::from(path); - if path.extension().is_none() { - path.set_extension("json"); - } - match path.parent() { - Some(dir) if dir.is_dir() => {} - _ => return Err(format!("{} isn't in a folder that exists.", path.display())), - } + use super::Params; - // Two very different intents share this one command, told apart by - // whether the target already holds a library: - // - // * an existing document — "open that library", so the working copy - // is dropped and rebuilt from it; - // * a new file — "keep my library here from now on", so the working - // copy is *kept* and published into it. - // - // Getting this wrong destroys data: always resetting would discard - // everything the user had whenever they pointed at a fresh file. - let adopting = path.is_file(); - db.lock().unwrap().set_document_path(Some(path)); - if adopting { - reset_working_copy(&app, &db)?; - } else { - // Clear only the merge base, so the new file is seen as empty and - // the whole current library gets published into it. - let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - let _ = std::fs::remove_file(base_path(&data_dir)); - } - // Either way, get the document and the working copy agreeing now rather - // than at some later trigger. - if let Err(e) = sync_now(app.clone(), db.clone()) { - crate::mlog!("sync_set_document: initial sync failed: {e}"); - } - sync_status(app, db) - } -} + const DONE_PAGE: &str = "\ + mtgc\ +

You're signed in. You can close this tab and return to mtgc.

"; -/// The library files already sitting in the granted folder. -/// -/// Android grants a folder, not a file, so the user has to say which of its -/// documents is theirs. Without this the app assumes "library.json" and would -/// silently create an empty second library beside one named anything else. -#[tauri::command] -pub fn sync_list_documents( - #[allow(unused_variables)] app: AppHandle, - db: Db, -) -> Result, String> { - let location = locate(&app, &db)?; - let mut names: Vec = location - .list(&app)? - .into_iter() - .filter(|n| n.ends_with(".json") && !n.ends_with(".json.tmp")) - .collect(); - names.sort(); - Ok(names) -} - -/// Android only: choose which file in the granted folder is the library. -/// -/// Switching to a different file means adopting a different library, so the -/// working copy is dropped and rebuilt from it — the same rule desktop applies -/// when opening an existing document. -#[tauri::command] -pub fn sync_set_document_name( - app: AppHandle, - db: Db, - #[allow(unused_variables)] name: String, -) -> Result { - #[cfg(not(target_os = "android"))] - { - let _ = (&app, &db, name); - return Err("This platform chooses the library file by full path.".into()); - } - #[cfg(target_os = "android")] - { - let mut name = name.trim().to_string(); - if name.is_empty() { - return Err("Give the library file a name.".into()); + /// Answer one connection. Returns the query parameters when it was the + /// sign-in callback. + pub fn handle(mut stream: TcpStream) -> Option { + let _ = stream.set_nonblocking(false); + let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); + let mut buf = Vec::new(); + let mut chunk = [0u8; 2048]; + // Only the request line matters; stop at the end of the headers. + while !buf.windows(4).any(|w| w == b"\r\n\r\n") && buf.len() < 16 * 1024 { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + } } - if !name.ends_with(".json") { - name.push_str(".json"); - } - // Keep it a plain file name: a path would escape the granted folder, - // which is the whole boundary the SAF grant establishes. - if name.contains('/') || name.contains('\\') { - return Err("The library file must sit directly in the chosen folder.".into()); - } - - let existed = { - let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - let location = locate(&app, &db)?; - location.read(&app, &data_dir, &name)?.is_some() + let head = String::from_utf8_lossy(&buf); + let params = parse_request_line(head.lines().next().unwrap_or_default()); + let (status, body) = match ¶ms { + Some(_) => ("200 OK", DONE_PAGE), + None => ("404 Not Found", ""), }; - db.lock().unwrap().set_document_name(Some(name)); - if existed { - // Adopting an existing library: start from it, not from whatever - // this device happened to be holding. - reset_working_copy(&app, &db)?; - } else { - let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - let _ = std::fs::remove_file(base_path(&data_dir)); + let _ = write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\ + Cache-Control: no-store\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + params + } + + /// `GET /callback?code=…&state=… HTTP/1.1` → the query parameters. + pub fn parse_request_line(line: &str) -> Option { + let mut parts = line.split_whitespace(); + if parts.next()? != "GET" { + return None; } - if let Err(e) = sync_now(app.clone(), db.clone()) { - crate::mlog!("sync_set_document_name: initial sync failed: {e}"); + let target = parts.next()?; + let (path, query) = target.split_once('?').unwrap_or((target, "")); + if path != "/callback" { + return None; } - sync_status(app, db) + Some(url::form_urlencoded::parse(query.as_bytes()).into_owned().collect()) } } +/// Android: the server sends the browser to `mtgc://auth/callback`, which the +/// deep-link plugin hands to [`deliver_callback`]. +#[cfg(target_os = "android")] +struct Callback { + rx: std::sync::mpsc::Receiver, +} + +#[cfg(target_os = "android")] +static WAITING: Mutex>> = Mutex::new(None); + +/// Called with every `mtgc://` URL the app is opened with. +#[cfg(target_os = "android")] +pub fn deliver_callback(url: &str) { + if !url.starts_with("mtgc://auth/callback") { + return; + } + match WAITING.lock().unwrap_or_else(|e| e.into_inner()).as_ref() { + Some(tx) => { + let _ = tx.send(url.to_string()); + } + // The app was restarted while the browser was open, so the verifier + // this callback needs is gone. The user just signs in again. + None => crate::mlog!("sync: ignoring a sign-in callback nothing is waiting for"), + } +} + +#[cfg(target_os = "android")] +impl Callback { + fn listen() -> Result { + let (tx, rx) = std::sync::mpsc::channel(); + *WAITING.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx); + Ok(Self { rx }) + } + + fn redirect_uri(&self) -> String { + "mtgc://auth/callback".into() + } + + fn wait(self, cancel: &AtomicBool, deadline: Instant) -> Result { + let result = loop { + if cancel.load(Ordering::SeqCst) { + break Err("Sign-in cancelled.".into()); + } + if Instant::now() > deadline { + break Err("Sign-in timed out. Try again.".into()); + } + match self.rx.recv_timeout(Duration::from_millis(250)) { + Ok(url) => { + let query = url.split_once('?').map(|(_, q)| q).unwrap_or_default(); + break Ok(url::form_urlencoded::parse(query.as_bytes()).into_owned().collect()); + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(e) => break Err(e.to_string()), + } + }; + *WAITING.lock().unwrap_or_else(|e| e.into_inner()) = None; + result + } +} + +/// Sign this device out. The library stays here, and keeps working offline. #[tauri::command] -pub fn sync_forget_document(app: AppHandle, db: Db) -> Result { - #[cfg(target_os = "android")] - { - crate::logged("sync_forget_document", bridge::forget_folder(&app))?; - } - #[cfg(not(target_os = "android"))] - { - db.lock().unwrap().set_document_path(None); - } - sync_status(app, db) +pub async fn sync_sign_out(app: AppHandle) -> Result { + blocking(move || { + let account = lock(&app).sync_account().cloned(); + if let Some(a) = account { + if let Some(token) = &a.token { + // Best effort: offline, the token stays valid on the server + // until revoked from another device, but this one forgets it. + if let Err(e) = HttpRemote::new(&a.server_url, token).logout() { + crate::mlog!("sync_sign_out: server logout failed: {e}"); + } + } + } + let mut s = lock(&app); + s.update_sync_account(|a| a.token = None); + s.sync_error = None; + s.sync_needs_choice = false; + drop(s); + Ok(status(&app)) + }) + .await } -/// Empty the working copy and forget the merge base, so the next sync adopts -/// whatever the document says from scratch. -fn reset_working_copy(app: &AppHandle, db: &Db) -> Result<(), String> { - let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - let _ = std::fs::remove_file(base_path(&data_dir)); - - let mut s = db.lock().unwrap(); - let working = s.working_db_path.clone(); - // Close first: the file can't be replaced while the catalog holds it open. - s.close(); - let _ = std::fs::remove_file(&working); - if let Err(e) = s.reopen() { - crate::mlog!("reset_working_copy: reopen failed: {e}"); - } - Ok(()) -} +// --- syncing -------------------------------------------------------------------- /// Outcome of one sync. #[derive(Serialize, Default)] pub struct SyncReport { + /// Whether this device's library changed. pub changed: bool, pub published: bool, - /// Conflict copies absorbed and cleaned up, if any. - pub absorbed: usize, + /// The first sync is waiting on the user: see [`sync_connect`]. + pub needs_choice: bool, pub report: MergeReport, } -/// Reconcile the working copy with the library document. +/// Reconcile the working copy with the server. /// -/// `Ok(None)` when a sync was already in flight and this call stood down. Sync -/// runs automatically on launch and on every foreground/background transition, -/// so overlap is routine rather than exceptional. +/// `Ok(None)` when there is nothing to do — not signed in — or a sync was +/// already in flight and this call stood down. Sync runs automatically on +/// launch, on reconnecting, and on every foreground/background transition, so +/// overlap is routine rather than exceptional. #[tauri::command] -pub fn sync_now(app: AppHandle, db: Db) -> Result, String> { +pub async fn sync_now(app: AppHandle) -> Result, String> { + run_sync(app, None).await +} + +/// Answer the first-sync question: combine this device's library with the +/// server's, or replace it with the server's. +#[tauri::command] +pub async fn sync_connect(app: AppHandle, choice: FirstSync) -> Result, String> { + run_sync(app, Some(choice)).await +} + +async fn run_sync(app: AppHandle, choice: Option) -> Result, String> { { - let mut state = db.lock().unwrap(); - if state.sync_running { + let mut s = lock(&app); + if s.sync_running || s.sync_account().and_then(|a| a.token.as_ref()).is_none() { return Ok(None); } - state.sync_running = true; + s.sync_running = true; } - let result = sync_inner(&app, &db); - db.lock().unwrap().sync_running = false; - result.map(Some) -} + let app2 = app.clone(); + let result = blocking(move || sync_inner(&app2, choice)).await; -fn sync_inner(app: &AppHandle, db: &Db) -> Result { - let location = locate(app, db)?; - let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - let working = db.lock().unwrap().working_db_path.clone(); - - // 1. Read the document. Absent simply means nothing has been written yet — - // the file the user named gets created by the first sync. - let existing = location.read(app, &data_dir, &location.doc_name)?; - let doc_exists = existing.is_some(); - let mut theirs = match existing { - Some(text) => LibraryDoc::from_json(&text).map_err(|e| e.to_string())?, - None => LibraryDoc::default(), - }; - - // 2. Absorb any conflict copies, so nothing is stranded in one. - let base = read_base(&data_dir); - let copies = location.conflict_copies(app); - let mut absorbed = Vec::new(); - for name in &copies { - let Ok(Some(text)) = location.read(app, &data_dir, name) else { - continue; - }; - let Ok(copy) = LibraryDoc::from_json(&text) else { - continue; // Not one of ours, or truncated mid-sync; leave it alone. - }; - let (merged, _) = core_sync::merge3(&base, &theirs, ©); - theirs = merged; - absorbed.push(name.clone()); - } - - // 3-4. Merge our edits with theirs and make the working copy match. - let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?; - let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?; - let (merged, report) = core_sync::merge3(&base, &ours, &theirs); - if merged != ours { - core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?; - } - - // 5. Publish only when we have something to contribute, or the file the - // user named doesn't exist yet — otherwise every sync would look like a - // modification to whatever is watching the folder. - let published = merged != theirs || !doc_exists; - if published { - let text = merged.to_json().map_err(|e| e.to_string())?; - location.write(app, &data_dir, &text)?; - } - - // Only now that their content is safely in the published document is it - // safe to remove the copies it came from. - if published { - for name in &absorbed { - if let Err(e) = location.delete(app, name) { - crate::mlog!("sync: couldn't remove absorbed copy {name}: {e}"); + let mut s = lock(&app); + s.sync_running = false; + match &result { + Ok(r) => { + s.sync_error = None; + s.sync_needs_choice = r.needs_choice; + if !r.needs_choice { + s.update_sync_account(|a| a.last_synced_at = Some(unix_now())); } } - } - - // 6. Remember the document *as read*, never the merged result. If the write - // above was lost, the remote still matches this base, so the next sync sees - // our edits as ours and republishes them instead of reading their absence - // from the document as a deletion. - write_base(&data_dir, &theirs); - - crate::mlog!( - "sync_now: changed={} published={} absorbed={} conflicts={}", - !report.is_empty(), - published, - absorbed.len(), - report.conflicts.len() - ); - - Ok(SyncReport { - changed: !report.is_empty(), - published, - absorbed: absorbed.len(), - report, - }) -} - -/// The document as last read. Missing or unreadable yields an empty document, -/// which is the safe default: with no ancestor, a merge never deletes anything. -fn read_base(data_dir: &Path) -> LibraryDoc { - std::fs::read_to_string(base_path(data_dir)) - .ok() - .and_then(|t| LibraryDoc::from_json(&t).ok()) - .unwrap_or_default() -} - -fn write_base(data_dir: &Path, doc: &LibraryDoc) { - if let Ok(text) = doc.to_json() { - let _ = std::fs::write(base_path(data_dir), text); - } -} - -/// Convert a pre-document `library.db` into the current format, merging it into -/// the working copy so the next sync publishes it. -#[tauri::command] -pub fn convert_legacy_library(app: AppHandle, db: Db, path: String) -> Result { - let legacy = PathBuf::from(&path); - if !legacy.is_file() { - return Err(format!("{} isn't there.", legacy.display())); - } - let doc = crate::logged( - "convert_legacy_library", - mtgc_core::sync::convert_legacy_library(&legacy).map_err(|e| e.to_string()), - )?; - - let working = db.lock().unwrap().working_db_path.clone(); - let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?; - let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?; - // Merged rather than applied outright, so converting doesn't discard - // anything already in the working copy. With an empty ancestor nothing is - // treated as a deletion. - let (merged, report) = core_sync::merge3(&LibraryDoc::default(), &ours, &doc); - core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?; - - db.lock().unwrap().clear_legacy_library(); - crate::mlog!( - "convert_legacy_library: {} cards, {} decks from {}", - doc.cards.len(), - doc.decks.len(), - legacy.display() - ); - - // Publish immediately when a document is already configured, so the - // converted library reaches the other devices without waiting. - if locate(&app, &db).is_ok() { - if let Some(r) = sync_now(app, db)? { - return Ok(r); + Err(e) if e == UNAUTHORIZED => { + // Revoked from another device, or the account was removed. + // Retrying can't help, so stop trying until the user signs in. + s.update_sync_account(|a| a.token = None); + s.sync_error = Some(e.clone()); } + Err(e) => s.sync_error = Some(e.clone()), } + drop(s); + crate::logged("sync_now", result).map(Some) +} + +fn sync_inner(app: &AppHandle, choice: Option) -> Result { + let (account, data_dir, working) = { + let s = lock(app); + let account = s.sync_account().cloned().ok_or("Not signed in.")?; + (account, s.data_dir().to_path_buf(), s.working_db_path.clone()) + }; + let token = account.token.as_deref().ok_or("Not signed in.")?; + let remote = HttpRemote::new(&account.server_url, token); + + let mut base = match read_base(&data_dir, &account) { + Some(base) => base, + None => { + // Never synced with this account's library from here, so there is + // no ancestor to tell edits from deletions with. + use mtgc_core::sync::remote::Remote; + let theirs = remote.get().map_err(|e| e.to_string())?; + let ours = { + let _s = lock(app); + local_doc(&working)? + }; + match first_sync_plan(&ours, &theirs.doc, choice) { + None => return Ok(SyncReport { needs_choice: true, ..Default::default() }), + // An empty ancestor: nothing on either side reads as deleted. + Some(FirstSync::Combine) => SyncBase::default(), + Some(FirstSync::UseServer) => { + let _s = lock(app); + let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?; + core_sync::apply_doc(&conn, &theirs.doc).map_err(|e| e.to_string())?; + SyncBase { version: theirs.version, doc: theirs.doc } + } + } + } + }; + + // The state lock is held only while the working copy is read, merged and + // rewritten — every edit command takes the same lock, so none can land in + // between and be overwritten. The network calls happen without it. + let result = sync_cycle(&remote, &mut base, |base_doc, theirs| { + let _s = lock(app); + let conn = mtgc_core::db::open_library(&working)?; + core_sync::merge_into(&conn, base_doc, theirs) + }); + // Saved whether or not the cycle finished: it only ever records what is + // known to be on the server, so a partial run's base is still sound. + write_base(&data_dir, &account, &base); + let cycle = result.map_err(|e| e.to_string())?; + + crate::mlog!( + "sync_now: changed={} published={} version={} conflicts={}", + !cycle.report.is_empty(), + cycle.published, + cycle.version, + cycle.report.conflicts.len() + ); Ok(SyncReport { - changed: !report.is_empty(), - published: false, - absorbed: 0, - report, + changed: !cycle.report.is_empty(), + published: cycle.published, + needs_choice: false, + report: cycle.report, }) } +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +// --- devices ---------------------------------------------------------------------- + +fn signed_in_remote(app: &AppHandle) -> Result { + let s = lock(app); + let a = s.sync_account().ok_or("Not signed in.")?; + let token = a.token.as_deref().ok_or("Not signed in.")?; + Ok(HttpRemote::new(&a.server_url, token)) +} + +/// The account's signed-in devices. +#[tauri::command] +pub async fn sync_devices(app: AppHandle) -> Result, String> { + blocking(move || { + let me = signed_in_remote(&app)?.me().map_err(|e| e.to_string())?; + Ok(me.devices) + }) + .await +} + +/// Sign out another of the account's devices — a lost phone, say. +#[tauri::command] +pub async fn sync_revoke_device(app: AppHandle, id: i64) -> Result, String> { + blocking(move || { + let remote = signed_in_remote(&app)?; + remote.revoke_device(id).map_err(|e| e.to_string())?; + Ok(remote.me().map_err(|e| e.to_string())?.devices) + }) + .await +} + +// --- import and export ---------------------------------------------------------- + +/// What an import added. +#[derive(Serialize)] +pub struct ImportReport { + pub cards: usize, + pub decks: usize, + pub report: MergeReport, +} + +/// Merge a library file into this device's library: a JSON library document +/// (a backup, or the file a folder-synced build kept), or a `library.db` from +/// before documents existed. Nothing already here is lost — it's merged with +/// an empty ancestor — and the next sync shares the result. +#[tauri::command] +pub async fn import_library_file(app: AppHandle, path: String) -> Result { + let app2 = app.clone(); + let report = blocking(move || { + let file = PathBuf::from(&path); + if !file.is_file() { + return Err(format!("{} isn't there.", file.display())); + } + let is_json = file.extension().is_some_and(|e| e.eq_ignore_ascii_case("json")); + let doc = if is_json { + let text = std::fs::read_to_string(&file).map_err(|e| e.to_string())?; + LibraryDoc::from_json(&text).map_err(|e| format!("Not a library file: {e}"))? + } else { + core_sync::convert_legacy_library(&file).map_err(|e| e.to_string())? + }; + + let mut s = lock(&app2); + let conn = mtgc_core::db::open_library(&s.working_db_path).map_err(|e| e.to_string())?; + let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?; + let (merged, report) = core_sync::merge3(&LibraryDoc::default(), &ours, &doc); + core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?; + if s.legacy_library_path() == Some(file.as_path()) { + s.clear_legacy_library(); + } + crate::mlog!( + "import_library_file: {} cards, {} decks from {}", + doc.cards.len(), + doc.decks.len(), + file.display() + ); + Ok(ImportReport { cards: doc.cards.len(), decks: doc.decks.len(), report }) + }) + .await; + let report = crate::logged("import_library_file", report)?; + // Share it now rather than at the next trigger. + let _ = sync_now(app).await; + Ok(report) +} + +/// Save the whole library as a JSON document — a backup that +/// [`import_library_file`] reads back. +#[tauri::command] +pub async fn export_library_document(app: AppHandle, path: String) -> Result<(), String> { + blocking(move || { + let doc = { + let s = lock(&app); + local_doc(&s.working_db_path)? + }; + let text = doc.to_json().map_err(|e| e.to_string())?; + std::fs::write(&path, text).map_err(|e| format!("Couldn't save {path}: {e}")) + }) + .await +} + #[cfg(test)] mod tests { use super::*; + fn account(server: &str, user: &str) -> SyncAccount { + SyncAccount { + server_url: server.into(), + token: Some("t".into()), + user: AccountUser { + id: user.into(), + username: user.into(), + display_name: String::new(), + email: String::new(), + method: "local".into(), + }, + device_id: 1, + last_synced_at: None, + } + } + fn scratch(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("mtgc-doc-{name}-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!("mtgc-sync-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } #[test] - fn writing_replaces_the_document_and_leaves_no_temporary() { - // A stray ".tmp" would be picked up as a conflict copy and, worse, - // synced as a partial document. - let dir = scratch("write"); - plain::write(&dir, "library.json", "{\"version\":1}").unwrap(); - plain::write(&dir, "library.json", "{}").unwrap(); + fn a_base_is_only_used_with_the_account_it_came_from() { + let dir = scratch("base"); + let mine = account("https://a.example", "u1"); + let base = SyncBase { version: 4, ..Default::default() }; + write_base(&dir, &mine, &base); - assert_eq!(plain::read(&dir, "library.json").unwrap().unwrap(), "{}"); - assert_eq!(plain::list(&dir).unwrap(), vec!["library.json"]); + assert_eq!(read_base(&dir, &mine), Some(base)); + assert_eq!(read_base(&dir, &account("https://a.example", "u2")), None); + assert_eq!(read_base(&dir, &account("https://b.example", "u1")), None); + assert!(!dir.join("sync-server-base.json.tmp").exists()); std::fs::remove_dir_all(&dir).unwrap(); } #[test] - fn reading_a_document_that_isnt_there_is_not_an_error() { - // Normal before anything has been published. - let dir = scratch("missing"); - assert_eq!(plain::read(&dir, "library.json").unwrap(), None); + fn a_folder_sync_era_base_is_never_read() { + // That build's file held a bare document under a different name. + let dir = scratch("old-base"); + std::fs::write(dir.join("sync-base.json"), r#"{"version":1,"cards":{},"decks":{}}"#).unwrap(); + assert_eq!(read_base(&dir, &account("https://a.example", "u1")), None); std::fs::remove_dir_all(&dir).unwrap(); } + #[cfg(not(target_os = "android"))] #[test] - fn conflict_copies_are_matched_by_shape_not_by_tool() { - let dir = scratch("conflicts"); - for name in [ - "library.json", - "library.sync-conflict-20260803-120000-ABCDEFG.json", // Syncthing - "library (SFConflict garrettmills 2026-08-03).json", // Seafile - "library (conflicted copy 2026-08-03).json", // Dropbox - "library.json.tmp", // our own, mid-write - "notes.json", // unrelated - "reference.db", // unrelated - ] { - std::fs::write(dir.join(name), "{}").unwrap(); - } - - let found = conflict_copies_of("library.json", plain::list(&dir).unwrap()); - + fn the_loopback_listener_takes_only_the_callback() { assert_eq!( - found, - vec![ - "library (SFConflict garrettmills 2026-08-03).json", - "library (conflicted copy 2026-08-03).json", - "library.sync-conflict-20260803-120000-ABCDEFG.json", - ], - "should catch every tool's naming, and nothing else" + loopback::parse_request_line("GET /callback?code=abc%2B1&state=xyz HTTP/1.1"), + Some(vec![("code".into(), "abc+1".into()), ("state".into(), "xyz".into())]) ); - std::fs::remove_dir_all(&dir).unwrap(); + assert_eq!(loopback::parse_request_line("GET /favicon.ico HTTP/1.1"), None); + assert_eq!(loopback::parse_request_line("POST /callback?code=a HTTP/1.1"), None); + assert_eq!(loopback::parse_request_line(""), None); } + #[cfg(not(target_os = "android"))] #[test] - fn a_differently_named_document_only_claims_its_own_copies() { - // The user names their own file on desktop, so the rule has to key off - // that name rather than assuming "library". - let found = conflict_copies_of( - "Decks.json", - vec![ - "Decks.json".into(), - "Decks (conflicted copy).json".into(), - "library.sync-conflict-1.json".into(), - "Decks-backup.txt".into(), - ], - ); + fn the_loopback_listener_answers_a_real_browser_request() { + use std::io::{Read, Write}; + let cb = Callback::listen().unwrap(); + let addr = cb.redirect_uri().replace("http://", "").replace("/callback", ""); + let cancel = AtomicBool::new(false); + let browser = std::thread::spawn(move || { + // A stray request first, as browsers make. + let mut s = std::net::TcpStream::connect(&addr).unwrap(); + s.write_all(b"GET /favicon.ico HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); + let mut out = String::new(); + s.read_to_string(&mut out).unwrap(); + assert!(out.starts_with("HTTP/1.1 404")); - assert_eq!(found, vec!["Decks (conflicted copy).json"]); + let mut s = std::net::TcpStream::connect(&addr).unwrap(); + s.write_all(b"GET /callback?code=c&state=s HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); + let mut out = String::new(); + s.read_to_string(&mut out).unwrap(); + assert!(out.starts_with("HTTP/1.1 200")); + assert!(out.contains("signed in")); + }); + + let params = cb.wait(&cancel, Instant::now() + Duration::from_secs(10)).unwrap(); + browser.join().unwrap(); + assert!(params.contains(&("code".into(), "c".into()))); + } + + #[cfg(not(target_os = "android"))] + #[test] + fn a_sign_in_can_be_abandoned() { + let cb = Callback::listen().unwrap(); + let cancel = AtomicBool::new(true); + let err = cb.wait(&cancel, Instant::now() + Duration::from_secs(10)).unwrap_err(); + assert!(err.contains("cancelled")); } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d95cd29..b31eb24 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,6 +23,13 @@ "csp": null } }, + "plugins": { + "deep-link": { + "mobile": [ + { "scheme": ["mtgc"], "host": "auth", "path": ["/callback"], "appLink": false } + ] + } + }, "bundle": { "active": true, "targets": ["deb", "rpm"], diff --git a/src/App.tsx b/src/App.tsx index f7e78d9..4449ff4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useState, useCallback, ReactNode } from "react"; import { listen } from "@tauri-apps/api/event"; -import { open, save } from "@tauri-apps/plugin-dialog"; +import { open } from "@tauri-apps/plugin-dialog"; import { api, IngestProgress, IngestStageInfo, LibraryStats, Status, SyncStatus } from "./api"; import { ColorDistributionBar, + formatRelativeTime, HoverPreviewProvider, IconButton, Icons, @@ -12,6 +13,7 @@ import { usePersistedState, } from "./components"; import { CommandPalette } from "./CommandPalette"; +import { SyncIndicator, SyncPanel } from "./SyncPanel"; import { CardEntry } from "./views/CardEntry"; import { LibraryBrowser } from "./views/LibraryBrowser"; import { CardSearch } from "./views/CardSearch"; @@ -39,14 +41,6 @@ const ICON_OPEN_LIBRARY = svg( , 16, ); -const ICON_NEW_LIBRARY = svg( - <> - - - - , - 16, -); const ICON_DOWNLOAD = svg( <> @@ -145,21 +139,6 @@ const INFO_TAB: { id: Tab; label: string; icon: ReactNode } = { ), }; -/** Compact relative time, e.g. "2h ago" — for the "database updated" stamp. */ -function formatRelativeTime(epochSeconds: number): string { - const diffSec = Math.round((Date.now() - epochSeconds * 1000) / 1000); - if (diffSec < 30) return "just now"; - const mins = Math.round(diffSec / 60); - if (mins < 60) return `${mins}m ago`; - const hours = Math.round(mins / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.round(hours / 24); - if (days < 30) return `${days}d ago`; - const months = Math.round(days / 30); - if (months < 12) return `${months}mo ago`; - return `${Math.round(months / 12)}y ago`; -} - /** "Your library" summary: unique/copy/deck counts, color distribution, and * the reference-db freshness stamp. Shared by the desktop nav rail's footer * and the mobile Info tab, which otherwise have no equivalent place for it. */ @@ -231,9 +210,10 @@ function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: n // so missing the snackbar isn't the last chance to undo. const UNDO_SNACKBAR_MS = 8000; -/** How often an open, visible window re-syncs. Long enough not to churn the - * synced folder, short enough that a second device sees edits without being - * restarted. Launch and foreground/background transitions sync too. */ +/** How often an open, visible window re-syncs. Long enough not to load the + * server, short enough that a second device sees edits without being + * restarted. Launch, reconnecting, and foreground/background transitions sync + * too. */ const AUTO_SYNC_MS = 3 * 60 * 1000; export default function App() { @@ -242,14 +222,11 @@ export default function App() { const [tab, setTab] = usePersistedState("active-tab", "add"); const [progress, setProgress] = useState(null); const [busy, setBusy] = useState(false); - // Synced-folder grant for library sync. Only fetched when the Info tab is - // open: the grant can be revoked from system settings while the app is - // backgrounded, so a value cached at startup would be stale exactly when the - // user comes back to check it. + // Library sync state: signed in or not, and how the last sync went. Fetched + // at startup, after every sync, and whenever the Info tab opens. const [sync, setSync] = useState(null); - const [syncBusy, setSyncBusy] = useState(false); - // Result of the last round-trip self-test, shown until the next action. - const [syncTest, setSyncTest] = useState<{ ok: boolean; message: string } | null>(null); + // A sync is in flight, for the top-bar indicator. + const [syncing, setSyncing] = useState(false); const lastProgress = useRef(null); // Set by the ingest-progress listener below; reset before each // updateCardData() call so it can detect a stalled invoke — see there. @@ -439,212 +416,82 @@ export default function App() { } - /** Refresh the synced-folder grant state. Never throws: "no folder chosen" is - * a normal state, reported in the panel rather than as an error. */ + /** Refresh the sync state. Never throws: being offline or signed out is a + * normal state, reported in the panel rather than as an error. */ const refreshSync = useCallback(async () => { - setSyncBusy(true); try { setSync(await api.syncStatus()); } catch (err) { - setSyncTest({ ok: false, message: String(err) }); - } finally { - setSyncBusy(false); + console.error("syncStatus failed:", err); } }, []); - // Once at startup: the automatic sync below needs to know whether a folder is - // configured at all. + // Once at startup: the automatic sync below needs to know whether this + // device is signed in at all. + // + // Gated on `statusLoaded`: `getStatus` retries until the backend is ready, + // so waiting on it guarantees setup() has registered the state these + // commands need. useEffect(() => { if (statusLoaded) refreshSync(); }, [statusLoaded, refreshSync]); - // And whenever the Info tab is opened, which is also when the user gets back - // from the folder picker, and when a revoked grant would first matter. - // - // Gated on `statusLoaded` as well as the tab: the active tab is persisted, so - // relaunching while on Info makes this fire at mount — before setup() has - // called manage(), which fails with "state not managed for field db". - // `getStatus` retries until the backend is ready, so waiting on it is what - // guarantees the state exists. + // And whenever the Info tab is opened, so "synced 5m ago" is current there. useEffect(() => { if (tab === "info" && statusLoaded) refreshSync(); }, [tab, statusLoaded, refreshSync]); - /** - * Point the app at `path` and bring the whole UI into line with it. - * - * Switching documents changes the entire library underneath every view, so - * refreshing the sync panel alone isn't enough: the path bar reads - * `status.library_path`, the sidebar reads `libStats`, and each tab is - * holding rows fetched from the *previous* library. Returns a short " — N - * cards, M decks" suffix so the caller can confirm what actually opened. + * Bring every view into line after the library may have changed wholesale: + * signing in or out, adopting the server's copy, importing a file. The top + * bar's label comes from get_status, which nothing else re-fetches; the + * sidebar reads `libStats`; and each tab holds rows it fetched earlier. */ - async function adoptDocument(path: string): Promise { - setSync(await api.syncSetDocument(path)); - // The path bar comes from get_status, which nothing else re-fetches. - setStatus(await api.getStatus()); + const refreshEverything = useCallback(() => { + api.getStatus().then(setStatus).catch(() => {}); + if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {}); setDeckRefreshSignal((v) => v + 1); - if (!readyRef.current) return ""; - try { - const stats = await api.getLibraryStats(); - setLibStats(stats); - return ` — ${stats.unique_cards} card${stats.unique_cards === 1 ? "" : "s"}, ${ - stats.deck_count - } deck${stats.deck_count === 1 ? "" : "s"}`; - } catch { - return ""; - } - } - - /** Choose where the library lives. Android grants a folder through the system - * picker (SAF has no path to hand back); desktop picks the JSON file itself. */ - async function pickLibrary() { - setSyncTest(null); - try { - if (sync?.picker_based) { - setSyncBusy(true); - setSync(await api.syncPickFolder()); - return; - } - const path = await open({ - title: "Choose your library file", - // Start where the library currently lives, so the dialog opens - // somewhere useful rather than at an arbitrary default. - defaultPath: sync?.label ?? undefined, - filters: [ - { name: "mtgc library", extensions: ["json"] }, - { name: "All files", extensions: ["*"] }, - ], - }); - if (typeof path !== "string") return; - setSyncBusy(true); - const opened = await adoptDocument(path); - setSyncTest({ ok: true, message: `Opened ${path}${opened}.` }); - } catch (err) { - setSyncTest({ ok: false, message: String(err) }); - } finally { - setSyncBusy(false); - } - } - - /** Desktop: start a new library file, e.g. inside a synced folder. */ - async function newLibrary() { - setSyncTest(null); - try { - const path = await save({ - title: "Save your library here (put it in a synced folder)", - defaultPath: "mtgc-library.json", - filters: [{ name: "mtgc library", extensions: ["json"] }], - }); - if (typeof path !== "string") return; - setSyncBusy(true); - // Pointing at a file that doesn't exist yet moves the current library - // there rather than starting empty — see sync_set_document. - const moved = await adoptDocument(path); - setSyncTest({ ok: true, message: `Your library is now kept at ${path}${moved}.` }); - } catch (err) { - setSyncTest({ ok: false, message: String(err) }); - } finally { - setSyncBusy(false); - } - } - - /** Android's second step: which file in the granted folder is the library. - * Loaded lazily, since it costs a SAF round trip. */ - const [docChoices, setDocChoices] = useState(null); - useEffect(() => { - if (tab !== "info" || !sync?.picker_based || !sync.granted) return; - api.syncListDocuments().then(setDocChoices).catch(() => setDocChoices([])); - }, [tab, sync?.picker_based, sync?.granted, sync?.label]); - - async function chooseDocumentName(name: string) { - setSyncBusy(true); - setSyncTest(null); - try { - setSync(await api.syncSetDocumentName(name)); - setStatus(await api.getStatus()); - setDeckRefreshSignal((v) => v + 1); - if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {}); - setSyncTest({ ok: true, message: `Using ${name}.` }); - } catch (err) { - setSyncTest({ ok: false, message: String(err) }); - } finally { - setSyncBusy(false); - } - } - - async function forgetLibrary() { - setSyncBusy(true); - setSyncTest(null); - try { - setSync(await api.syncForgetDocument()); - } catch (err) { - setSyncTest({ ok: false, message: String(err) }); - } finally { - setSyncBusy(false); - } - } - - /** Convert a library.db from an older build into the current format. */ - async function convertLegacy(path: string) { - setSyncBusy(true); - setSyncTest(null); - try { - const r = await api.convertLegacyLibrary(path); - const n = r.report.cards_changed + r.report.decks_changed; - const status = await api.syncStatus(); - setSync(status); - // Say where it landed — otherwise a successful conversion looks like - // nothing happened. - setSyncTest({ - ok: true, - message: `Converted your old library (${n} entries). It's now kept at ${status.label ?? "your library file"}.`, - }); - if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {}); - setDeckRefreshSignal((v) => v + 1); - } catch (err) { - setSyncTest({ ok: false, message: String(err) }); - } finally { - setSyncBusy(false); - } - } - - /** Merge the library document with local edits and publish the result. - * - * Shared by the Info tab's button and the automatic triggers below. Returns - * the report so the caller can decide whether to say anything; throws only - * what the backend threw. */ - const runSync = useCallback(async () => { - const report = await api.syncNow(); - if (!report) return null; // Another sync had it in hand. - const changed = report.changed; - if (changed) { - // Library and deck views hold fetched copies; a merge changes rows - // underneath them, so pull them back into line rather than waiting for - // the summary's 5s poll. Needs card data loaded, hence the guard. - if (readyRef.current) { - api.getLibraryStats().then(setLibStats).catch(() => {}); - } - setDeckRefreshSignal((v) => v + 1); - } - return { report, changed }; }, []); - // Automatic sync. Runs once the folder is usable, then on every trip to or - // from the background: leaving publishes what changed here, returning picks - // up what changed elsewhere. Keyed on `granted` alone so merely re-checking - // the folder's status doesn't re-arm the listeners and resync. - const syncGranted = sync?.granted ?? false; + /** Merge with the server and publish local edits. + * + * Shared by the Info tab's button and the automatic triggers below. Resolves + * with the report (null when nothing ran); throws only what the backend + * threw. Either way the sync state is re-read, so the indicator follows. */ + const runSync = useCallback(async () => { + setSyncing(true); + try { + const report = await api.syncNow(); + if (report?.changed) { + // Library and deck views hold fetched copies; a merge changes rows + // underneath them, so pull them back into line rather than waiting + // for the summary's 5s poll. Needs card data loaded, hence the guard. + if (readyRef.current) { + api.getLibraryStats().then(setLibStats).catch(() => {}); + } + setDeckRefreshSignal((v) => v + 1); + } + return report; + } finally { + setSyncing(false); + api.syncStatus().then(setSync).catch(() => {}); + } + }, []); + + // Automatic sync, whenever signed in (and not waiting on the first-sync + // choice): once now, on every trip to or from the background — leaving + // publishes what changed here, returning picks up what changed elsewhere — + // and on reconnecting. Keyed on that flag alone so re-reading the status + // after each sync doesn't re-arm the listeners and sync again. + const syncActive = (sync?.signed_in ?? false) && !(sync?.needs_choice ?? false); useEffect(() => { - if (!syncGranted) return; + if (!syncActive) return; let cancelled = false; - // Failures here are deliberately silent: the folder can be temporarily - // unavailable (drive unmounted, grant revoked), and nagging on every app - // switch would be worse than the Info tab reporting it on demand. Nothing - // is lost by a skipped run — the merge base only advances to a document we - // actually read, so a lost publish is simply re-detected next time. + // Failures surface through the indicator rather than as errors: being + // offline is routine, and nothing is lost by a skipped run — edits stay + // in the working copy, and the merge base only ever records what the + // server confirmed, so the next run picks up exactly where this left off. const run = () => { if (cancelled) return; runSync().catch(() => {}); @@ -652,6 +499,7 @@ export default function App() { run(); document.addEventListener("visibilitychange", run); + window.addEventListener("online", run); // Visibility changes alone would strand a desktop session that simply stays // open: edits made after launch would never reach the other devices. Poll // while visible so an always-open window still publishes. Skipped while @@ -663,49 +511,9 @@ export default function App() { cancelled = true; clearInterval(timer); document.removeEventListener("visibilitychange", run); + window.removeEventListener("online", run); }; - }, [syncGranted, runSync]); - - /** The Info tab's explicit "Sync now" — same work, but reports the outcome. */ - async function syncNow() { - setSyncBusy(true); - setSyncTest(null); - try { - const result = await runSync(); - if (!result) { - setSyncTest({ ok: true, message: "A sync was already running." }); - return; - } - const { report, changed } = result; - const parts: string[] = []; - if (changed) { - const r = report.report; - const bits = [ - r.cards_changed && `${r.cards_changed} card${r.cards_changed === 1 ? "" : "s"}`, - r.decks_changed && `${r.decks_changed} deck${r.decks_changed === 1 ? "" : "s"}`, - r.decks_removed && `${r.decks_removed} removed`, - ].filter(Boolean); - parts.push(`Merged ${bits.join(", ")}.`); - } else { - parts.push(report.published ? "Published your changes." : "Already up to date."); - } - if (report.absorbed > 0) { - parts.push( - `Absorbed ${report.absorbed} conflict cop${report.absorbed === 1 ? "y" : "ies"}.`, - ); - } - // Conflicts are the one thing worth spelling out — the user may want to - // correct a value that lost. - if (report.report.conflicts.length > 0) { - parts.push(`Kept the other copy's version of: ${report.report.conflicts.join("; ")}.`); - } - setSyncTest({ ok: true, message: parts.join(" ") }); - } catch (err) { - setSyncTest({ ok: false, message: String(err) }); - } finally { - setSyncBusy(false); - } - } + }, [syncActive, runSync]); /** Adopt a reference.db built elsewhere (e.g. copied from a desktop install) * instead of streaming ~740MB to rebuild it on this device. */ @@ -762,23 +570,10 @@ export default function App() {
- - {status?.library_path ?? ""} + + {status?.library_label ?? ""} - {/* Desktop-only: Android grants a folder through the system picker - instead of naming a path, so these live in the Info tab there. */} - {status && !status.is_mobile && ( - <> - - - - )} + setTab("info")} /> {/* On mobile this lives in the bottom nav's Info tab instead — see below — rather than crowding the topbar. */} {status && !status.is_mobile && ( @@ -844,8 +639,7 @@ export default function App() { No card database yet. Download card data fetches the latest cards from Scryfall (a few hundred MB {status?.is_mobile ? " — slow over mobile data" : ", about a minute"}). - The reference data stays on this device — only your library file is meant - to be synced. + The reference data stays on this device — only your library is synced.

{status?.is_mobile && (

@@ -865,20 +659,20 @@ export default function App() {

) : tab === "add" ? ( - ) : /* Keyed on the library document: switching to a different one - replaces every card, deck and id these views are holding, so + ) : /* Keyed on the sync account: switching to a different one can + replace every card, deck and id these views are holding, so they're remounted rather than left to reconcile state that belongs to a library that's no longer open. Stable across ordinary status refreshes, so it costs nothing otherwise. */ tab === "library" ? ( - + ) : tab === "search" ? ( - + ) : tab === "decks" ? ( - + ) : tab === "news" ? ( // Library-independent, so unlike the others it isn't keyed on the - // document — switching libraries doesn't change the news. + // account — switching libraries doesn't change the news. ) : ( // The per-install status page: card data, library sync, and the @@ -889,9 +683,6 @@ export default function App() { // lives only here.

Card database

-
- {status?.library_path} -

-

Your library

{sync === null ? ( -
Checking…
- ) : ( <> -
- Your collection is kept in a single file. Put it in a folder you sync and - every device stays in step — edits made in two places are combined, not - overwritten. -
- {sync.label && ( -
- {sync.label} -
- )} - {sync.chosen && !sync.granted && ( -
- ✕{" "} - {sync.picker_based - ? "Access to that folder was lost — choose it again." - : "That folder isn't there right now — is the drive mounted?"} -
- )} - {syncTest && ( -
- {syncTest.ok ? "✓ " : "✕ "} - {syncTest.message} -
- )} -
- {sync.granted && ( - - )} - - {sync.chosen && ( - - )} -
- {/* Android grants a *folder*, so which file in it is the - library is a separate choice. Without this the app - assumes "library.json" and would create an empty second - library beside one named anything else. */} - {sync.picker_based && sync.chosen && sync.granted && ( -
- - {docChoices === null ? ( -
Looking…
- ) : ( - <> - - {docChoices.length === 0 && ( - - No library files here yet — a new one will be created. - - )} - - )} -
- )} - - {/* Only shown while an old library.db is still waiting; the - offer disappears for good once it's been converted. */} - {sync.legacy_library && ( - <> -
- An older library from a previous version is still on this device. Convert - it to bring your collection across. -
-
- {sync.legacy_library} -
-
- -
- - )} +

Your library

+
Checking…
+ ) : ( + )}
@@ -1058,8 +744,7 @@ export default function App() { runSync().catch(() => {})} undo={undo} onRunUndo={runUndo} /> diff --git a/src/CommandPalette.tsx b/src/CommandPalette.tsx index 4006006..c8dbd5d 100644 --- a/src/CommandPalette.tsx +++ b/src/CommandPalette.tsx @@ -3,7 +3,7 @@ import { api, CardRef, Deck } from "./api"; import { DialogFrame, useClickPreview } from "./components"; // Kept in step with App.tsx's Tab union by hand — it isn't exported. -type Tab = "add" | "library" | "search" | "decks" | "news"; +type Tab = "add" | "library" | "search" | "decks" | "news" | "info"; interface PaletteItem { key: string; @@ -38,15 +38,13 @@ function subsequenceMatch(query: string, text: string): boolean { export function CommandPalette({ onNavigate, onUpdateCardData, - onOpenLibrary, - onNewLibrary, + onSyncNow, undo, onRunUndo, }: { onNavigate: (tab: Tab) => void; onUpdateCardData: () => void; - onOpenLibrary: () => void; - onNewLibrary: () => void; + onSyncNow: () => void; /** The most recent undoable action, if its window hasn't lapsed yet. */ undo: { message: string; run: () => void | Promise } | null; onRunUndo: () => void; @@ -98,8 +96,8 @@ export function CommandPalette({ { key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") }, { key: "nav-news", label: "Go to News", run: () => onNavigate("news") }, { key: "update-card-data", label: "Update card data", run: onUpdateCardData }, - { key: "open-library", label: "Switch library file…", run: onOpenLibrary }, - { key: "new-library", label: "Create new library file…", run: onNewLibrary }, + { key: "sync-now", label: "Sync library now", run: onSyncNow }, + { key: "nav-sync", label: "Sync settings", run: () => onNavigate("info") }, ]; const items: PaletteItem[] = [ diff --git a/src/SyncPanel.tsx b/src/SyncPanel.tsx new file mode 100644 index 0000000..85405fc --- /dev/null +++ b/src/SyncPanel.tsx @@ -0,0 +1,424 @@ +import { useEffect, useState } from "react"; +import { open, save } from "@tauri-apps/plugin-dialog"; +import { api, FirstSync, SyncDevice, SyncReport, SyncStatus } from "./api"; +import { ConfirmDialog, formatRelativeTime } from "./components"; + +/** The server's answer when this device's token stops working. */ +const SIGNED_OUT_BY_SERVER = "This device is no longer signed in to the sync server."; + +/** A one-line summary of a sync, for the panel's outcome line. */ +export function describeSync(report: SyncReport): string { + if (report.needs_choice) return "Choose how to combine the two libraries below."; + const r = report.report; + const parts: string[] = []; + if (report.changed) { + const bits = [ + r.cards_changed && `${r.cards_changed} card${r.cards_changed === 1 ? "" : "s"}`, + r.decks_changed && `${r.decks_changed} deck${r.decks_changed === 1 ? "" : "s"}`, + r.decks_removed && `${r.decks_removed} removed`, + ].filter(Boolean); + parts.push(`Brought in ${bits.join(", ")} from your other devices.`); + } + if (report.published) parts.push("Sent this device's changes."); + if (parts.length === 0) parts.push("Already up to date."); + // Conflicts are the one thing worth spelling out — the user may want to + // correct a value that lost. + if (r.conflicts.length > 0) { + parts.push(`Changed in two places, so the server's version was kept for: ${r.conflicts.join("; ")}.`); + } + return parts.join(" "); +} + +/** + * Compact sync state for the top bar. Clicking it opens the Info tab, where + * the details and controls live. + */ +export function SyncIndicator({ + sync, + syncing, + onClick, +}: { + sync: SyncStatus | null; + syncing: boolean; + onClick: () => void; +}) { + if (!sync) return null; + let tone: "ok" | "warn" | "off" = "ok"; + let label: string; + if (!sync.signed_in) { + tone = "off"; + label = sync.last_error === SIGNED_OUT_BY_SERVER ? "Signed out" : "Not syncing"; + } else if (sync.needs_choice) { + tone = "warn"; + label = "Sync needs you"; + } else if (syncing) { + label = "Syncing…"; + } else if (sync.last_error) { + tone = "warn"; + label = sync.pending_changes ? "Offline — changes will sync" : "Offline"; + } else if (sync.pending_changes) { + tone = "warn"; + label = "Not yet synced"; + } else { + label = "Synced"; + } + const title = sync.last_error + ? `Last sync failed: ${sync.last_error}` + : sync.last_synced_at + ? `Last synced ${formatRelativeTime(sync.last_synced_at)}` + : label; + return ( + + ); +} + +/** + * The Info tab's library sync section: signing in to a sync server, the + * first-sync choice, the account's devices, and library backups. + */ +export function SyncPanel({ + sync, + setSync, + syncing, + runSync, + onLibraryReplaced, +}: { + sync: SyncStatus; + setSync: (s: SyncStatus) => void; + syncing: boolean; + /** Sync now; resolves with the report, or null when nothing ran. */ + runSync: () => Promise; + /** The library may have changed wholesale (signed in, imported, adopted the + * server's copy): every view should refetch. */ + onLibraryReplaced: () => void; +}) { + const [serverUrl, setServerUrl] = useState(sync.server_url ?? ""); + const [deviceName, setDeviceName] = useState(""); + const [busy, setBusy] = useState(false); + const [outcome, setOutcome] = useState<{ ok: boolean; message: string } | null>(null); + const [devices, setDevices] = useState(null); + const [confirm, setConfirm] = useState< + { kind: "use_server" } | { kind: "revoke"; device: SyncDevice } | null + >(null); + + useEffect(() => { + if (!serverUrl && sync.server_url) setServerUrl(sync.server_url); + }, [sync.server_url]); // eslint-disable-line react-hooks/exhaustive-deps + + // The device list is only worth a round trip while it's on screen. + useEffect(() => { + if (!sync.signed_in) { + setDevices(null); + return; + } + api.syncDevices().then(setDevices).catch(() => setDevices(null)); + }, [sync.signed_in, sync.account?.id]); + + async function act(work: () => Promise) { + setBusy(true); + setOutcome(null); + try { + const message = await work(); + if (message) setOutcome({ ok: true, message }); + } catch (err) { + // Backing out of a sign-in is a choice, not a failure worth flagging. + if (!String(err).includes("Sign-in cancelled")) { + setOutcome({ ok: false, message: String(err) }); + } + } finally { + setBusy(false); + api.syncStatus().then(setSync).catch(() => {}); + } + } + + const signIn = () => + act(async () => { + if (!serverUrl.trim()) throw new Error("Enter your sync server's address."); + // Shows "finish signing in in your browser" while this waits. + setSync({ ...sync, signing_in: true }); + const s = await api.syncSignIn(serverUrl.trim(), deviceName.trim() || undefined); + setSync(s); + onLibraryReplaced(); + return s.needs_choice ? null : `Signed in as ${s.account?.username}. Your library is syncing.`; + }); + + const cancelSignIn = () => api.syncCancelSignIn().catch(() => {}); + + const signOut = () => + act(async () => { + setSync(await api.syncSignOut()); + onLibraryReplaced(); + return "Signed out. Your library stays on this device and keeps working offline."; + }); + + const syncNow = () => + act(async () => { + const r = await runSync(); + return r ? describeSync(r) : "A sync was already running."; + }); + + const connect = (choice: FirstSync) => + act(async () => { + const r = await api.syncConnect(choice); + onLibraryReplaced(); + return r ? describeSync(r) : null; + }); + + const revoke = (device: SyncDevice) => + act(async () => { + setDevices(await api.syncRevokeDevice(device.id)); + return `Signed out ${device.name}.`; + }); + + const exportLibrary = () => + act(async () => { + const path = await save({ + title: "Save a backup of your library", + defaultPath: `mtgc-library-${new Date().toISOString().slice(0, 10)}.json`, + filters: [{ name: "mtgc library", extensions: ["json"] }], + }); + if (typeof path !== "string") return null; + await api.exportLibraryDocument(path); + return `Saved to ${path}.`; + }); + + const importLibrary = (preset?: string) => + act(async () => { + const path = + preset ?? + (await open({ + title: "Import a library file", + filters: [ + { name: "mtgc library", extensions: ["json", "db"] }, + { name: "All files", extensions: ["*"] }, + ], + })); + if (typeof path !== "string") return null; + const r = await api.importLibraryFile(path); + onLibraryReplaced(); + return `Imported ${r.cards} card${r.cards === 1 ? "" : "s"} and ${r.decks} deck${ + r.decks === 1 ? "" : "s" + }, combined with what was already here.`; + }); + + const account = sync.account; + + return ( + <> +

Your library

+ + {!sync.signed_in ? ( + <> +
+ Your library is kept on this device and works offline. Sign in to a sync server to + share it with your other devices — edits made in two places are combined, not + overwritten. +
+ {sync.last_error === SIGNED_OUT_BY_SERVER && ( +
+ ✕ This device was signed out by the server (removed from another device, perhaps). + Sign in again to resume syncing. +
+ )} + {sync.signing_in ? ( + <> +
Finish signing in in your browser…
+
+ +
+ + ) : ( + <> +
+ + setServerUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !busy && signIn()} + disabled={busy} + /> +
+
+ + setDeviceName(e.target.value)} + disabled={busy} + /> +
+
+ +
+ + )} + + ) : ( + <> +
+ {account?.display_name && account.display_name !== account.username + ? `${account.display_name} (${account.username})` + : account?.username} + {account?.email && · {account.email}} +
+
+ {sync.server_url} + {account?.method === "oidc" ? " · single sign-on" : ""} +
+ + {sync.needs_choice ? ( +
+ This device and your account both have a library. +
+ Combining keeps everything from both. Using the server's library replaces what's on + this device. +
+
+ + +
+
+ ) : ( +
+ {sync.last_error + ? `✕ Couldn't sync: ${sync.last_error}${ + sync.pending_changes ? " Your changes are safe here and will sync later." : "" + }` + : syncing + ? "Syncing…" + : sync.pending_changes + ? "Changes on this device haven't synced yet." + : sync.last_synced_at + ? `✓ Synced ${formatRelativeTime(sync.last_synced_at)}` + : "✓ Synced"} +
+ )} + +
+ {!sync.needs_choice && ( + + )} + +
+ + )} + + {outcome && ( +
+ {outcome.ok ? "✓ " : "✕ "} + {outcome.message} +
+ )} + + {sync.signed_in && devices && devices.length > 0 && ( +
+ +
    + {devices.map((d) => ( +
  • + + {d.name} + {d.current && This device} + + seen {formatRelativeTime(d.last_seen_at)} + {!d.current && ( + + )} +
  • + ))} +
+
+ )} + +
+ +
+ + +
+ + Import combines a backup, or a library.json from an older version, with this one. + +
+ + {/* Only shown while an old library.db is still waiting; the offer + disappears for good once it's been imported. */} + {sync.legacy_library && ( + <> +
+ An older library from a previous version is still on this device. Import it to bring + your collection across. +
+
{sync.legacy_library}
+
+ +
+ + )} + + {confirm?.kind === "use_server" && ( + setConfirm(null)} + onConfirm={() => { + setConfirm(null); + connect("use_server"); + }} + /> + )} + {confirm?.kind === "revoke" && ( + setConfirm(null)} + onConfirm={() => { + const d = confirm.device; + setConfirm(null); + revoke(d); + }} + /> + )} + + ); +} diff --git a/src/api.ts b/src/api.ts index 38cec03..94e81ce 100644 --- a/src/api.ts +++ b/src/api.ts @@ -136,7 +136,8 @@ export interface LibraryFilter { } export interface Status { - library_path: string; + /** Where the library syncs: "user @ host", or this device only. */ + library_label: string; reference_path: string; reference_ready: boolean; card_count: number; @@ -147,41 +148,74 @@ export interface Status { is_mobile: boolean; } -/** State of the user's library document — the JSON file they keep in whatever - * folder they sync. The SQLite database behind it is internal and never shown. */ +/** The account a device is signed in to on a sync server. */ +export interface SyncAccount { + id: string; + username: string; + display_name: string; + email: string; + /** "local" (username/password) or "oidc" (single sign-on). */ + method: string; +} + +/** Library sync with a sync server. The library itself always lives on this + * device and works offline; syncing exchanges edits with the other devices. */ export interface SyncStatus { - /** Whether a document has been chosen. */ - chosen: boolean; - /** Whether it's currently reachable. Distinct from `chosen`: an Android grant - * can be revoked and a desktop folder can be deleted or unmounted — both need - * re-picking rather than first-time setup. */ - granted: boolean; - label: string | null; - /** True where the folder is chosen through the system picker (Android) rather - * than by path — the one thing the UI branches on. */ - picker_based: boolean; - /** A pre-document library.db from an older build, waiting to be converted. */ + signed_in: boolean; + /** Kept after signing out, to offer signing back in. */ + server_url: string | null; + account: SyncAccount | null; + /** Unix seconds of the last completed sync. */ + last_synced_at: number | null; + /** Why the last sync failed, until one succeeds. */ + last_error: string | null; + /** The first sync found libraries both here and on the server; waiting on + * `syncConnect` to say whether to combine them or take the server's. */ + needs_choice: boolean; + /** Edits made here that the server hasn't acknowledged yet. */ + pending_changes: boolean; + /** A sign-in is waiting on the browser. */ + signing_in: boolean; + /** A pre-document library.db from an older build, waiting to be imported. */ legacy_library: string | null; - /** Which file in the granted folder is the library (Android). Null on - * desktop, where the whole path is chosen at once. */ - document_name: string | null; +} + +/** One of the account's signed-in devices. */ +export interface SyncDevice { + id: number; + name: string; + created_at: number; + last_seen_at: number; + /** Whether it's this device. */ + current: boolean; +} + +export type FirstSync = "combine" | "use_server"; + +export interface MergeReport { + cards_changed: number; + decks_changed: number; + decks_removed: number; + /** Fields both sides changed, where the server's value was kept. */ + conflicts: string[]; +} + +/** What an import of a library file brought in. */ +export interface ImportReport { + cards: number; + decks: number; + report: MergeReport; } /** What one sync did. */ export interface SyncReport { - /** Whether the merge altered the local library. */ + /** Whether the merge altered this device's library. */ changed: boolean; - /** Whether the document was rewritten (skipped when we had nothing to add). */ + /** Whether the server's copy was replaced (skipped when there was nothing to add). */ published: boolean; - /** Conflict copies absorbed and cleaned up. */ - absorbed: number; - report: { - cards_changed: number; - decks_changed: number; - decks_removed: number; - /** Fields both sides changed differently, described in user terms. */ - conflicts: string[]; - }; + /** The first sync is waiting on the user — see `SyncStatus.needs_choice`. */ + needs_choice: boolean; + report: MergeReport; } /** A published or upcoming Magic set, from Scryfall. */ @@ -299,22 +333,26 @@ export const api = { /** Adopt an already-built reference.db from `path` instead of downloading it. */ importReferenceDb: (path: string) => invoke("import_reference_db", { path }), syncStatus: () => invoke("sync_status"), - /** Merge the library document with local edits and publish the result. - * Resolves with `null` when a sync was already running and this call stood - * down — expected, since sync also fires automatically. */ + /** Sign in through the browser, then sync. Resolves once the user finishes + * (or abandons) signing in there. */ + syncSignIn: (serverUrl: string, deviceName?: string) => + invoke("sync_sign_in", { serverUrl, deviceName: deviceName || null }), + syncCancelSignIn: () => invoke("sync_cancel_sign_in"), + /** Sign this device out. The library stays here. */ + syncSignOut: () => invoke("sync_sign_out"), + /** Merge with the server and publish local edits. Resolves with `null` when + * not signed in, or a sync was already running and this call stood down — + * expected, since sync also fires automatically. */ syncNow: () => invoke("sync_now"), - /** Android: grant access to a folder through the system picker. */ - syncPickFolder: () => invoke("sync_pick_folder"), - /** Desktop: adopt a library document chosen with the file dialog. */ - syncSetDocument: (path: string) => invoke("sync_set_document", { path }), - syncForgetDocument: () => invoke("sync_forget_document"), - /** Library files already in the chosen folder, for Android's second step. */ - syncListDocuments: () => invoke("sync_list_documents"), - /** Android: choose which file in the granted folder is the library. */ - syncSetDocumentName: (name: string) => - invoke("sync_set_document_name", { name }), - /** Convert a pre-document library.db into the current format. */ - convertLegacyLibrary: (path: string) => invoke("convert_legacy_library", { path }), + /** Answer the first-sync question. */ + syncConnect: (choice: FirstSync) => invoke("sync_connect", { choice }), + syncDevices: () => invoke("sync_devices"), + /** Sign another device out (a lost phone, say); returns the remaining list. */ + syncRevokeDevice: (id: number) => invoke("sync_revoke_device", { id }), + /** Merge a library file into this one: a JSON backup, or an old library.db. */ + importLibraryFile: (path: string) => invoke("import_library_file", { path }), + /** Save the whole library as JSON, as a backup. */ + exportLibraryDocument: (path: string) => invoke("export_library_document", { path }), /** Set releases and article links. `refresh` forces a fetch past the cache. */ getNews: (refresh = false) => invoke("get_news", { refresh }), diff --git a/src/components.tsx b/src/components.tsx index 7cbaa1e..158fdc0 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -1767,3 +1767,18 @@ function IconToggle({ ); } + +/** Compact relative time, e.g. "2h ago" — for "updated"/"synced" stamps. */ +export function formatRelativeTime(epochSeconds: number): string { + const diffSec = Math.round((Date.now() - epochSeconds * 1000) / 1000); + if (diffSec < 30) return "just now"; + const mins = Math.round(diffSec / 60); + if (mins < 60) return `${mins}m ago`; + const hours = Math.round(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.round(days / 30); + if (months < 12) return `${months}mo ago`; + return `${Math.round(months / 12)}y ago`; +} diff --git a/src/styles.css b/src/styles.css index b620682..faee9b8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -489,6 +489,9 @@ input[type="checkbox"] { .status-dot.off { background: var(--faint); } +.status-dot.warn { + background: var(--warn); +} /* Sidebar "your library" summary panel. */ .lib-stats-head { @@ -784,7 +787,57 @@ input[type="checkbox"] { gap: var(--space-3); max-width: 480px; } -/* The currently chosen synced folder. */ +/* Top-bar sync state; opens the Info tab. Quiet when all is well, so it + only draws the eye when something needs attention. */ +.sync-indicator { + flex-shrink: 0; + font-size: var(--fs-caption); + padding: 5px 10px; + white-space: nowrap; +} +.sync-indicator-warn { + border-color: var(--warn); + color: var(--warn); +} +/* First sync with an account that already has a library: a decision only the + user can make, so it's set apart from the surrounding status text. */ +.sync-choice { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--warn); + border-radius: var(--radius); +} +.sync-devices { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.sync-devices li { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} +.sync-device-name { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-weight: 600; + color: var(--text-strong); +} +.sync-devices .link-button { + margin-left: auto; + background: none; + border: none; + padding: 2px 4px; + color: var(--danger); +} +/* The signed-in account. */ .sync-folder { font-size: var(--fs-body); font-weight: 600;