Replace folder sync with an HTTP sync server, keeping the app offline-first

Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.

The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.

Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.

The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
This commit is contained in:
2026-10-08 22:32:08 -05:00
parent df2cbb372a
commit de55fc634a
41 changed files with 6365 additions and 1662 deletions

7
.dockerignore Normal file
View File

@@ -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

1046
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"

156
README.md
View File

@@ -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 <name> [--display-name <text>] # prompts for a password
mtgc-server user passwd <name>
mtgc-server user remove <name> --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

View File

@@ -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"

View File

@@ -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.

View File

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

View File

@@ -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<RemoteDoc>;
fn put(&self, if_match: u64, doc: &LibraryDoc) -> Result<PutOutcome>;
}
/// 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<SyncBase> {
(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<R, F>(remote: &R, base: &mut SyncBase, mut merge_local: F) -> Result<CycleReport>
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<FirstSync>,
) -> Option<FirstSync> {
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<RemoteDoc> {
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<PutOutcome> {
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<RemoteDoc> {
#[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<RemoteDoc>,
/// The next write is applied, but the reply is lost.
drop_next_reply: RefCell<bool>,
/// Another device writes this just before our next write lands.
sneak_in: RefCell<Option<LibraryDoc>>,
puts: RefCell<usize>,
}
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<RemoteDoc> {
Ok(self.state.borrow().clone())
}
fn put(&self, if_match: u64, doc: &LibraryDoc) -> Result<PutOutcome> {
*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<CycleReport> {
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<i64> {
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<RemoteDoc> {
self.0.get()
}
fn put(&self, _: u64, _: &LibraryDoc) -> Result<PutOutcome> {
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::<AccountBase>(&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}");
}
}

View File

@@ -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 }

View File

@@ -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"]

View File

@@ -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=

View File

@@ -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
}

View File

@@ -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:

View File

@@ -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<HashMap<String, Failures>>,
}
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<Duration> {
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<String, String> {
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<AppState>,
headers: HeaderMap,
Form(form): Form<LoginForm>,
) -> 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());
}
}

View File

@@ -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<String>,
state: String,
#[serde(default)]
device_name: Option<String>,
}
/// Step 1: begin a sign-in and show the way to complete it.
pub async fn start(State(state): State<AppState>, Query(p): Query<StartParams>) -> 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::<String>())
.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<PendingLogin, Response> {
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<AppState>,
Json(req): Json<TokenRequest>,
) -> Result<Response, ApiError> {
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<AppState> for Authed {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, ApiError> {
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<AppState>, auth: Authed) -> Result<Response, ApiError> {
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<AppState>, auth: Authed) -> Result<StatusCode, ApiError> {
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<AppState>,
auth: Authed,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
if db::delete_device(&state.db(), auth.user.id, id)? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError::new(StatusCode::NOT_FOUND, "no such device"))
}
}

View File

@@ -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<Option<(Instant, CoreProviderMetadata)>>,
}
impl Oidc {
pub fn new(config: OidcConfig) -> anyhow::Result<Self> {
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<CoreProviderMetadata> {
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<AppState>,
headers: HeaderMap,
Query(p): Query<BeginParams>,
) -> 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<String>,
code: Option<String>,
error: Option<String>,
}
/// The provider's answer.
pub async fn callback(
State(state): State<AppState>,
headers: HeaderMap,
Query(p): Query<CallbackParams>,
) -> 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<Identity> {
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,
})
}

View File

@@ -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<OidcConfig>,
}
#[derive(Debug, Clone)]
pub struct OidcConfig {
pub issuer: String,
pub client_id: String,
pub client_secret: String,
pub scopes: Vec<String>,
/// Shown on the sign-in button: "Sign in with <name>".
pub display_name: String,
/// When non-empty, only these (verified) addresses may sign in. Empty means
/// the provider alone decides who gets in, which is usually right: limiting
/// access to the application is a provider-side setting in Authentik and
/// Keycloak alike.
pub allowed_emails: Vec<String>,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
Self::from_lookup(|k| std::env::var(k).ok())
}
/// Read settings through `get`, so tests can supply them without touching
/// the process environment.
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
let get = |k: &str| get(k).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
let public_url = get("MTGC_PUBLIC_URL")
.context("MTGC_PUBLIC_URL is required — the https:// address the server is reached at")?
.trim_end_matches('/')
.to_string();
url::Url::parse(&public_url).context("MTGC_PUBLIC_URL isn't a valid URL")?;
let bind = get("MTGC_BIND")
.unwrap_or_else(|| "0.0.0.0:8080".into())
.parse()
.context("MTGC_BIND must look like 0.0.0.0:8080")?;
let db_path = PathBuf::from(get("MTGC_DB").unwrap_or_else(|| "mtgc-server.db".into()));
let local_auth = match get("MTGC_LOCAL_AUTH").as_deref() {
None => true,
Some(v) => parse_bool(v).context("MTGC_LOCAL_AUTH must be true or false")?,
};
let oidc = match get("MTGC_OIDC_ISSUER") {
None => None,
Some(issuer) => Some(OidcConfig {
issuer,
client_id: get("MTGC_OIDC_CLIENT_ID")
.context("MTGC_OIDC_CLIENT_ID is required when MTGC_OIDC_ISSUER is set")?,
client_secret: get("MTGC_OIDC_CLIENT_SECRET")
.context("MTGC_OIDC_CLIENT_SECRET is required when MTGC_OIDC_ISSUER is set")?,
scopes: get("MTGC_OIDC_SCOPES")
.unwrap_or_else(|| "openid email profile".into())
.split_whitespace()
.filter(|s| *s != "openid") // always requested
.map(str::to_string)
.collect(),
display_name: get("MTGC_OIDC_NAME").unwrap_or_else(|| "single sign-on".into()),
allowed_emails: get("MTGC_ALLOWED_EMAILS")
.map(|v| {
v.split(',')
.map(|e| e.trim().to_lowercase())
.filter(|e| !e.is_empty())
.collect()
})
.unwrap_or_default(),
}),
};
if !local_auth && oidc.is_none() {
bail!(
"no way to sign in: set MTGC_OIDC_ISSUER (and its client settings), \
or leave MTGC_LOCAL_AUTH enabled"
);
}
Ok(Self { public_url, bind, db_path, local_auth, oidc })
}
pub fn oidc_redirect_uri(&self) -> String {
format!("{}/auth/callback", self.public_url)
}
}
fn parse_bool(v: &str) -> Option<bool> {
match v.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
fn cfg(vars: &[(&str, &str)]) -> anyhow::Result<Config> {
let map: HashMap<String, String> =
vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
Config::from_lookup(|k| map.get(k).cloned())
}
#[test]
fn local_accounts_alone_need_only_the_public_url() {
let c = cfg(&[("MTGC_PUBLIC_URL", "https://mtgc.example/")]).unwrap();
assert!(c.local_auth);
assert!(c.oidc.is_none());
assert_eq!(c.oidc_redirect_uri(), "https://mtgc.example/auth/callback");
}
#[test]
fn disabling_every_sign_in_method_is_refused() {
let err = cfg(&[("MTGC_PUBLIC_URL", "https://m.example"), ("MTGC_LOCAL_AUTH", "false")])
.unwrap_err();
assert!(err.to_string().contains("no way to sign in"));
}
#[test]
fn oidc_needs_its_client_settings() {
let err = cfg(&[
("MTGC_PUBLIC_URL", "https://m.example"),
("MTGC_OIDC_ISSUER", "https://id.example/realms/home"),
])
.unwrap_err();
assert!(err.to_string().contains("MTGC_OIDC_CLIENT_ID"));
}
#[test]
fn oidc_settings_are_read() {
let c = cfg(&[
("MTGC_PUBLIC_URL", "https://m.example"),
("MTGC_LOCAL_AUTH", "off"),
("MTGC_OIDC_ISSUER", "https://id.example/realms/home"),
("MTGC_OIDC_CLIENT_ID", "mtgc"),
("MTGC_OIDC_CLIENT_SECRET", "s3cret"),
("MTGC_OIDC_NAME", "Authentik"),
("MTGC_ALLOWED_EMAILS", "Me@Example.com, you@example.com"),
])
.unwrap();
let o = c.oidc.unwrap();
assert!(!c.local_auth);
assert_eq!(o.scopes, vec!["email", "profile"]);
assert_eq!(o.allowed_emails, vec!["me@example.com", "you@example.com"]);
assert_eq!(o.display_name, "Authentik");
}
}

View File

@@ -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<Connection> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
init(conn)
}
pub fn open_in_memory() -> rusqlite::Result<Connection> {
init(Connection::open_in_memory()?)
}
fn init(conn: Connection) -> rusqlite::Result<Connection> {
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<User> {
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<User> {
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<Option<User>> {
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<Option<(User, String)>> {
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<User> {
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<bool> {
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<bool> {
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<Vec<(User, i64)>> {
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<i64> {
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<Option<Device>> {
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<Vec<Device>> {
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<bool> {
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<String>,
pub oidc_verifier: Option<String>,
}
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<Option<PendingLogin>> {
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<Option<(i64, String, String)>> {
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<Option<(i64, String)>> {
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<String>),
}
/// 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<PutResult> {
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)) = &current {
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<Vec<i64>> {
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()
}

View File

@@ -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<Inner>);
pub struct Inner {
pub config: Config,
db: Mutex<Connection>,
pub(crate) local: auth::local::LocalAuth,
pub(crate) oidc: Option<auth::oidc::Oidc>,
}
impl AppState {
pub fn new(config: Config, conn: Connection) -> anyhow::Result<Self> {
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<AppState>) -> Json<serde_json::Value> {
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<String>) -> 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<rusqlite::Error> 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<const N: usize>() -> [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()
}

View File

@@ -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<AppState>, auth: Authed) -> Result<Response, ApiError> {
let stored = db::get_library(&state.db(), auth.user.id)?;
Ok(Json(wire(stored)?).into_response())
}
pub async fn put_library(
State(state): State<AppState>,
auth: Authed,
headers: HeaderMap,
body: String,
) -> Result<Response, ApiError> {
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<Value, ApiError> {
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<i64, ApiError> {
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"))
}

View File

@@ -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 <name> [--display-name <text>]
//! mtgc-server user passwd <name>
//! mtgc-server user remove <name> --yes
//! mtgc-server user list
//! ```
//!
//! The `user` commands read the same `MTGC_DB` as the server, so in a container
//! 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 <name> [--display-name <text>]
mtgc-server user passwd <name>
mtgc-server user remove <name> --yes
mtgc-server user list";
fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
None | Some("serve") => serve(),
Some("user") => user(&args[1..]),
Some("-h" | "--help" | "help") => {
println!("{USAGE}");
Ok(())
}
Some(other) => bail!("unknown command {other:?}\n{USAGE}"),
}
}
fn serve() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "mtgc_server=info".into()),
)
.init();
let config = Config::from_env()?;
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<rusqlite::Connection> {
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<String> {
let n = normalize_username(args.get(i).map(String::as_str).unwrap_or_default());
if n.is_empty() {
bail!("a username is required\n{USAGE}");
}
if n.len() > 64 || n.chars().any(|c| c.is_whitespace() || c.is_control()) {
bail!("usernames are up to 64 characters, without spaces");
}
Ok(n)
};
match args.first().map(String::as_str) {
Some("add") => {
let username = name(1)?;
let display_name = flag(args, "--display-name").unwrap_or_else(|| username.clone());
let conn = open_db()?;
if db::local_user(&conn, &username)?.is_some() {
bail!("{username} already exists — use `user passwd` to change the password");
}
let hash = hash_password(&read_new_password()?).map_err(anyhow::Error::msg)?;
db::create_local_user(&conn, &username, &display_name, &hash)?;
println!("Created {username}.");
}
Some("passwd") => {
let username = name(1)?;
let conn = open_db()?;
if db::local_user(&conn, &username)?.is_none() {
bail!("no local account named {username}");
}
let hash = hash_password(&read_new_password()?).map_err(anyhow::Error::msg)?;
db::set_local_password(&conn, &username, &hash)?;
println!("Password changed for {username}. Devices already signed in stay signed in.");
}
Some("remove") => {
let username = name(1)?;
if !args.iter().any(|a| a == "--yes") {
bail!(
"this deletes {username}'s account, signs out their devices, and deletes \
the library stored on this server (devices keep their own copies). \
Re-run with --yes to confirm."
);
}
if db::delete_local_user(&open_db()?, &username)? {
println!("Removed {username}.");
} else {
bail!("no local account named {username}");
}
}
Some("list") => {
let users = db::list_users(&open_db()?)?;
if users.is_empty() {
println!("No accounts yet. Add one with `mtgc-server user add <name>`.");
}
for (u, devices) in users {
let kind = if u.iss == db::LOCAL_ISS { "local".to_string() } else { format!("oidc {}", u.iss) };
let who = if u.email.is_empty() { u.display_name } else { format!("{} <{}>", u.display_name, u.email) };
println!("{:<24} {:<8} devices: {:<3} {}", u.sub, kind, devices, who);
}
}
_ => bail!("{USAGE}"),
}
Ok(())
}
fn flag(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
}
/// Prompt twice without echo at a terminal; otherwise read one line from
/// stdin, so it can be scripted (`echo "$PW" | mtgc-server user add me`).
fn read_new_password() -> anyhow::Result<String> {
let password = if std::io::stdin().is_terminal() {
let first = rpassword::prompt_password("New password: ")?;
let again = rpassword::prompt_password("Again: ")?;
if first != again {
bail!("the passwords didn't match");
}
first
} else {
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line)?;
line.trim_end_matches(['\r', '\n']).to_string()
};
if password.chars().count() < 8 {
bail!("use at least 8 characters");
}
Ok(password)
}

View File

@@ -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#"<p class="error" role="alert">{}</p>"#, esc(e)))
.unwrap_or_default();
let oidc = oidc_name
.map(|name| {
format!(
r#"<a class="button secondary" href="/auth/oidc?pending={id}">Sign in with {name}</a>
<div class="or"><span>or</span></div>"#,
id = esc(&pending.id),
name = esc(name),
)
})
.unwrap_or_default();
let body = format!(
r#"<h1>Sign in to mtgc</h1>
<p class="muted">Signing in <strong>{device}</strong> to sync your library.</p>
{error}
{oidc}
<form method="post" action="/auth/password">
<input type="hidden" name="pending" value="{id}">
<label>Username <input name="username" value="{username}" autocomplete="username" autocapitalize="none" required{focus_user}></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required{focus_password}></label>
<button type="submit">Sign in</button>
</form>"#,
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#"<h1>Returning to mtgc…</h1>
<p class="muted">If the app doesn't open by itself, use the button below. You can close this tab afterwards.</p>
<a class="button" href="{url}">Return to mtgc</a>"#
);
// 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#"<meta http-equiv="refresh" content="0;url={url}">"#);
page(StatusCode::OK, "Signed in", &body, &refresh)
}
pub fn error(status: StatusCode, message: &str) -> Response {
let body = format!(
r#"<h1>Sign-in didn't work</h1>
<p>{}</p>"#,
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#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
{head_extra}
<title>{title} · mtgc</title>
<style>{STYLE}</style>
</head>
<body><main>{body}</main></body>
</html>"#,
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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escaping_neutralises_markup_and_attribute_breakouts() {
assert_eq!(esc(r#"<b>"x" & 'y'</b>"#), "&lt;b&gt;&quot;x&quot; &amp; &#39;y&#39;&lt;/b&gt;");
}
}

View File

@@ -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<Body>) -> Response<Body> {
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<Body>) {
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<Body> {
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<Body> {
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<Body> {
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<Body> {
Request::get(uri).body(Body::empty()).unwrap()
}
fn post_json(uri: &str, body: Value, token: Option<&str>) -> Request<Body> {
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<Body>) -> String {
String::from_utf8(resp.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap()
}
async fn body_json(resp: Response<Body>) -> 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<String> {
let start = page.find(r#"href=""#)? + 6;
let href = &page[start..start + page[start..].find('"')?];
let href = href.replace("&amp;", "&");
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<String, Value> =
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");
}

View File

@@ -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();
}

View File

@@ -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<mtgc_core::sync::remote::CycleReport> {
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<i64> {
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}");
}

View File

@@ -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",

68
scripts/sync-server-image.sh Executable file
View File

@@ -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

View File

@@ -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"

View File

@@ -2,9 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Note: library sync needs no storage permission. The user grants access
to one folder through the system folder picker (SAF), and that grant is
persisted across restarts — see SyncPlugin.kt. -->
<!-- Library sync talks to the user's sync server over the network, so it
needs no storage permission. Sign-in returns to the app through the
mtgc:// scheme; the deep-link plugin adds that intent filter to the
activity below when the app is built. -->
<!-- AndroidTV support -->

View File

@@ -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}")
}
}
}

View File

@@ -20,7 +20,8 @@ type Res<T> = Result<T, String>;
/// 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<i64>,
/// 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),

View File

@@ -33,26 +33,14 @@ pub(crate) fn logged<T>(what: &str, result: Result<T, String>) -> Result<T, Stri
result
}
/// Register the Kotlin-side `SyncPlugin` and stash its handle in app state, so
/// the sync commands can call across to SAF. See `sync.rs` for why library sync
/// goes through a Java plugin rather than plain `std::fs`.
#[cfg(target_os = "android")]
fn sync_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
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,

View File

@@ -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<PathBuf>,
/// 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<PathBuf>,
/// 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<String>,
sync: Option<SyncAccount>,
}
/// 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<String>,
pub user: AccountUser,
#[serde(default)]
pub device_id: i64,
/// Unix seconds of the last sync that completed.
#[serde(default)]
pub last_synced_at: Option<i64>,
}
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<Catalog>,
@@ -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<mtgc_core::ingest::CancelToken>,
/// 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<String>,
/// 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<Arc<AtomicBool>>,
}
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<PathBuf>) {
self.config.document_path = path;
pub fn set_sync_account(&mut self, account: Option<SyncAccount>) {
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<String>) {
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();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,13 @@
"csp": null
}
},
"plugins": {
"deep-link": {
"mobile": [
{ "scheme": ["mtgc"], "host": "auth", "path": ["/callback"], "appLink": false }
]
}
},
"bundle": {
"active": true,
"targets": ["deb", "rpm"],

View File

@@ -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(
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />,
16,
);
const ICON_NEW_LIBRARY = svg(
<>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</>,
16,
);
const ICON_DOWNLOAD = svg(
<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
@@ -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<Tab>("active-tab", "add");
const [progress, setProgress] = useState<IngestProgress | null>(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<SyncStatus | null>(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<IngestProgress | null>(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<string> {
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<string[] | null>(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() {
<div className="main">
<header className="topbar">
<span className="path" title={status?.library_path ?? ""}>
{status?.library_path ?? ""}
<span className="path" title={status?.library_label ?? ""}>
{status?.library_label ?? ""}
</span>
{/* 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 && (
<>
<button onClick={pickLibrary} disabled={busy || syncBusy}>
{ICON_OPEN_LIBRARY}
Open Library
</button>
<button onClick={newLibrary} disabled={busy || syncBusy}>
{ICON_NEW_LIBRARY}
New Library
</button>
</>
)}
<SyncIndicator sync={sync} syncing={syncing} onClick={() => 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. <strong>Download card data</strong> 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.
</p>
{status?.is_mobile && (
<p className="hint">
@@ -865,20 +659,20 @@ export default function App() {
</div>
) : tab === "add" ? (
<CardEntry />
) : /* 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" ? (
<LibraryBrowser key={status?.library_path} onOfferUndo={offerUndo} />
<LibraryBrowser key={status?.library_label} onOfferUndo={offerUndo} />
) : tab === "search" ? (
<CardSearch key={status?.library_path} onOfferUndo={offerUndo} />
<CardSearch key={status?.library_label} onOfferUndo={offerUndo} />
) : tab === "decks" ? (
<DeckView key={status?.library_path} refreshSignal={deckRefreshSignal} />
<DeckView key={status?.library_label} refreshSignal={deckRefreshSignal} />
) : 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.
<NewsView />
) : (
// The per-install status page: card data, library sync, and the
@@ -889,9 +683,6 @@ export default function App() {
// lives only here.
<div className="panel info-panel">
<h2 style={{ margin: 0 }}>Card database</h2>
<div className="hint" style={{ wordBreak: "break-all" }}>
{status?.library_path}
</div>
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
<button className="primary" onClick={updateCardData} disabled={busy}>
{ICON_DOWNLOAD}
@@ -903,124 +694,19 @@ export default function App() {
</button>
</div>
<hr className="deck-section-divider" style={{ margin: 0 }} />
<h2 style={{ margin: 0 }}>Your library</h2>
{sync === null ? (
<div className="hint">Checking…</div>
) : (
<>
<div className="hint">
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.
</div>
{sync.label && (
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
{sync.label}
</div>
)}
{sync.chosen && !sync.granted && (
<div className="sync-bad">
✕{" "}
{sync.picker_based
? "Access to that folder was lost — choose it again."
: "That folder isn't there right now — is the drive mounted?"}
</div>
)}
{syncTest && (
<div className={syncTest.ok ? "sync-ok" : "sync-bad"}>
{syncTest.ok ? "✓ " : "✕ "}
{syncTest.message}
</div>
)}
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
{sync.granted && (
<button className="primary" onClick={syncNow} disabled={syncBusy}>
{syncBusy ? "Syncing…" : "Sync now"}
</button>
)}
<button
className={sync.granted ? "" : "primary"}
onClick={pickLibrary}
disabled={syncBusy}
>
{sync.chosen
? sync.picker_based
? "Change folder…"
: "Change file…"
: sync.picker_based
? "Choose folder…"
: "Choose file…"}
</button>
{sync.chosen && (
<button onClick={forgetLibrary} disabled={syncBusy}>
Forget
</button>
)}
</div>
{/* 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 && (
<div className="field">
<label>Library file in that folder</label>
{docChoices === null ? (
<div className="hint">Looking…</div>
) : (
<>
<select
value={sync.document_name ?? ""}
onChange={(e) => chooseDocumentName(e.target.value)}
disabled={syncBusy}
>
{/* The current name may not exist in the folder
yet (a library this device hasn't published),
so it's always offered. */}
{[
...new Set(
[sync.document_name ?? "library.json", ...docChoices].filter(
Boolean,
),
),
].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
{docChoices.length === 0 && (
<span className="hint">
No library files here yet — a new one will be created.
</span>
)}
</>
)}
</div>
)}
{/* Only shown while an old library.db is still waiting; the
offer disappears for good once it's been converted. */}
{sync.legacy_library && (
<>
<div className="hint">
An older library from a previous version is still on this device. Convert
it to bring your collection across.
</div>
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
{sync.legacy_library}
</div>
<div className="row">
<button
className="primary"
onClick={() => convertLegacy(sync.legacy_library!)}
disabled={syncBusy}
>
Convert old library
</button>
</div>
</>
)}
<h2 style={{ margin: 0 }}>Your library</h2>
<div className="hint">Checking…</div>
</>
) : (
<SyncPanel
sync={sync}
setSync={setSync}
syncing={syncing}
runSync={runSync}
onLibraryReplaced={refreshEverything}
/>
)}
<hr className="deck-section-divider" style={{ margin: 0 }} />
<LibrarySummary ready={ready} libStats={libStats} status={status} />
@@ -1058,8 +744,7 @@ export default function App() {
<CommandPalette
onNavigate={setTab}
onUpdateCardData={updateCardData}
onOpenLibrary={pickLibrary}
onNewLibrary={newLibrary}
onSyncNow={() => runSync().catch(() => {})}
undo={undo}
onRunUndo={runUndo}
/>

View File

@@ -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<void> } | 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[] = [

424
src/SyncPanel.tsx Normal file
View File

@@ -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 (
<button className={`sync-indicator sync-indicator-${tone}`} onClick={onClick} title={title}>
<span className={`status-dot ${tone === "ok" ? "on" : tone === "warn" ? "warn" : "off"}`} />
{label}
</button>
);
}
/**
* 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<SyncReport | null>;
/** 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<SyncDevice[] | null>(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<string | null>) {
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 (
<>
<h2 style={{ margin: 0 }}>Your library</h2>
{!sync.signed_in ? (
<>
<div className="hint">
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.
</div>
{sync.last_error === SIGNED_OUT_BY_SERVER && (
<div className="sync-bad">
✕ This device was signed out by the server (removed from another device, perhaps).
Sign in again to resume syncing.
</div>
)}
{sync.signing_in ? (
<>
<div className="sync-ok">Finish signing in in your browser…</div>
<div className="row">
<button onClick={cancelSignIn}>Cancel</button>
</div>
</>
) : (
<>
<div className="field">
<label htmlFor="sync-server">Sync server</label>
<input
id="sync-server"
type="url"
inputMode="url"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="mtgc.example.com"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !busy && signIn()}
disabled={busy}
/>
</div>
<div className="field">
<label htmlFor="sync-device">Name for this device (optional)</label>
<input
id="sync-device"
placeholder="e.g. Desktop, Phone"
value={deviceName}
onChange={(e) => setDeviceName(e.target.value)}
disabled={busy}
/>
</div>
<div className="row">
<button className="primary" onClick={signIn} disabled={busy || !serverUrl.trim()}>
Sign in…
</button>
</div>
</>
)}
</>
) : (
<>
<div className="sync-folder">
{account?.display_name && account.display_name !== account.username
? `${account.display_name} (${account.username})`
: account?.username}
{account?.email && <span className="hint"> · {account.email}</span>}
</div>
<div className="hint" style={{ overflowWrap: "anywhere" }}>
{sync.server_url}
{account?.method === "oidc" ? " · single sign-on" : ""}
</div>
{sync.needs_choice ? (
<div className="sync-choice">
<strong>This device and your account both have a library.</strong>
<div className="hint">
Combining keeps everything from both. Using the server's library replaces what's on
this device.
</div>
<div className="row" style={{ flexWrap: "wrap" }}>
<button className="primary" onClick={() => connect("combine")} disabled={busy}>
Combine them
</button>
<button onClick={() => setConfirm({ kind: "use_server" })} disabled={busy}>
Use the server's library
</button>
</div>
</div>
) : (
<div className={sync.last_error ? "sync-bad" : "sync-ok"}>
{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"}
</div>
)}
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
{!sync.needs_choice && (
<button className="primary" onClick={syncNow} disabled={busy || syncing}>
{busy || syncing ? "Syncing…" : "Sync now"}
</button>
)}
<button onClick={signOut} disabled={busy}>
Sign out
</button>
</div>
</>
)}
{outcome && (
<div className={outcome.ok ? "sync-ok" : "sync-bad"}>
{outcome.ok ? "✓ " : "✕ "}
{outcome.message}
</div>
)}
{sync.signed_in && devices && devices.length > 0 && (
<div className="field">
<label>Signed-in devices</label>
<ul className="sync-devices">
{devices.map((d) => (
<li key={d.id}>
<span className="sync-device-name">
{d.name}
{d.current && <span className="badge">This device</span>}
</span>
<span className="hint">seen {formatRelativeTime(d.last_seen_at)}</span>
{!d.current && (
<button
className="link-button"
onClick={() => setConfirm({ kind: "revoke", device: d })}
disabled={busy}
>
Sign out
</button>
)}
</li>
))}
</ul>
</div>
)}
<div className="field">
<label>Backup</label>
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
<button onClick={exportLibrary} disabled={busy}>
Export library…
</button>
<button onClick={() => importLibrary()} disabled={busy}>
Import library file…
</button>
</div>
<span className="hint">
Import combines a backup, or a library.json from an older version, with this one.
</span>
</div>
{/* Only shown while an old library.db is still waiting; the offer
disappears for good once it's been imported. */}
{sync.legacy_library && (
<>
<div className="hint">
An older library from a previous version is still on this device. Import it to bring
your collection across.
</div>
<div className="sync-folder">{sync.legacy_library}</div>
<div className="row">
<button
className="primary"
onClick={() => importLibrary(sync.legacy_library!)}
disabled={busy}
>
Import old library
</button>
</div>
</>
)}
{confirm?.kind === "use_server" && (
<ConfirmDialog
title="Use the server's library?"
message="This device's library will be replaced by the one on the server. Anything only on this device will be lost — export a backup first if you might want it."
confirmLabel="Replace this device's library"
danger
onCancel={() => setConfirm(null)}
onConfirm={() => {
setConfirm(null);
connect("use_server");
}}
/>
)}
{confirm?.kind === "revoke" && (
<ConfirmDialog
title={`Sign out ${confirm.device.name}?`}
message="It will stop syncing until someone signs in on it again. Its library stays on it."
confirmLabel="Sign out device"
onCancel={() => setConfirm(null)}
onConfirm={() => {
const d = confirm.device;
setConfirm(null);
revoke(d);
}}
/>
)}
</>
);
}

View File

@@ -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<Status>("import_reference_db", { path }),
syncStatus: () => invoke<SyncStatus>("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<SyncStatus>("sync_sign_in", { serverUrl, deviceName: deviceName || null }),
syncCancelSignIn: () => invoke<void>("sync_cancel_sign_in"),
/** Sign this device out. The library stays here. */
syncSignOut: () => invoke<SyncStatus>("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<SyncReport | null>("sync_now"),
/** Android: grant access to a folder through the system picker. */
syncPickFolder: () => invoke<SyncStatus>("sync_pick_folder"),
/** Desktop: adopt a library document chosen with the file dialog. */
syncSetDocument: (path: string) => invoke<SyncStatus>("sync_set_document", { path }),
syncForgetDocument: () => invoke<SyncStatus>("sync_forget_document"),
/** Library files already in the chosen folder, for Android's second step. */
syncListDocuments: () => invoke<string[]>("sync_list_documents"),
/** Android: choose which file in the granted folder is the library. */
syncSetDocumentName: (name: string) =>
invoke<SyncStatus>("sync_set_document_name", { name }),
/** Convert a pre-document library.db into the current format. */
convertLegacyLibrary: (path: string) => invoke<SyncReport>("convert_legacy_library", { path }),
/** Answer the first-sync question. */
syncConnect: (choice: FirstSync) => invoke<SyncReport | null>("sync_connect", { choice }),
syncDevices: () => invoke<SyncDevice[]>("sync_devices"),
/** Sign another device out (a lost phone, say); returns the remaining list. */
syncRevokeDevice: (id: number) => invoke<SyncDevice[]>("sync_revoke_device", { id }),
/** Merge a library file into this one: a JSON backup, or an old library.db. */
importLibraryFile: (path: string) => invoke<ImportReport>("import_library_file", { path }),
/** Save the whole library as JSON, as a backup. */
exportLibraryDocument: (path: string) => invoke<void>("export_library_document", { path }),
/** Set releases and article links. `refresh` forces a fetch past the cache. */
getNews: (refresh = false) => invoke<News>("get_news", { refresh }),

View File

@@ -1767,3 +1767,18 @@ function IconToggle({
</button>
);
}
/** 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`;
}

View File

@@ -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;