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:
7
.dockerignore
Normal file
7
.dockerignore
Normal 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
1046
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
156
README.md
@@ -11,17 +11,19 @@ in a platform-agnostic Rust core, so both platforms share it.
|
||||
```
|
||||
src/ React + TypeScript UI (swappable presentation)
|
||||
src-tauri/ Tauri v2 shell — exposes the core as typed commands
|
||||
crates/mtgc-core/ Pure Rust: db, fuzzy search, filters, decks, ingester
|
||||
crates/mtgc-core/ Pure Rust: db, fuzzy search, filters, decks, ingester, sync
|
||||
crates/mtgc-server/ Optional sync server: one library per user, OIDC and/or passwords
|
||||
```
|
||||
|
||||
Two files:
|
||||
Two kinds of data, both in the OS app-data dir (e.g.
|
||||
`~/.local/share/com.garrettmills.mtgc/`):
|
||||
|
||||
- **your library `.json`** — owned cards, decks, deck contents. Small,
|
||||
readable, and the only file you name or move. Put it somewhere synced; see
|
||||
[Sync](#sync).
|
||||
- **your library** — owned cards, decks, deck contents. Lives on each device
|
||||
and works fully offline; sign in to a sync server to share it between
|
||||
devices (see [Sync](#sync)). **Export library…** in the Info tab saves it as
|
||||
a JSON backup.
|
||||
- **`reference.db`** — read-only card data built from Scryfall bulk. Bulky and
|
||||
re-downloaded per device, so it's never synced. Lives in the OS app-data dir
|
||||
(e.g. `~/.local/share/com.garrettmills.mtgc/`).
|
||||
re-downloaded per device, so it's never synced.
|
||||
|
||||
## First-time setup
|
||||
|
||||
@@ -84,8 +86,8 @@ Notes:
|
||||
- The `mtgc` binary is a single file but relies on the system WebKit/GTK libs
|
||||
being present (installed by `devel-setup.sh`).
|
||||
- The binary does **not** embed card data — first run still downloads the Scryfall
|
||||
bulk into the OS app-data dir. Your library document defaults to that directory
|
||||
too; move it into a synced folder with **New Library** (see [Sync](#sync)).
|
||||
bulk into the OS app-data dir. Your library lives there too; to share it
|
||||
between devices, see [Sync](#sync).
|
||||
- The `[profile.release]` settings in `Cargo.toml` (strip + LTO) make the release
|
||||
build take a few minutes but keep the binary small.
|
||||
|
||||
@@ -119,42 +121,118 @@ Marvel Commander — isn't hidden.
|
||||
|
||||
## Sync
|
||||
|
||||
Your collection is a single JSON file — the only file you manage. Put it in a
|
||||
folder your sync tool watches (Syncthing, Dropbox, Nextcloud, Seafile, or a USB
|
||||
stick) and every device stays in step. Choose it with **Open Library** /
|
||||
**New Library**, or from the **Info** tab.
|
||||
Your library always lives on the device you're using, so the app works the
|
||||
same with or without a network. To keep several devices in step, run an
|
||||
[mtgc sync server](#running-the-sync-server) and sign in to it from each one:
|
||||
**Info → Sync server**, enter its address, and **Sign in…**. Sign-in happens
|
||||
in your browser, on the server's own page — with a username and password, or
|
||||
through your single sign-on provider, whichever the server offers — and the
|
||||
browser hands the app back a token for that device alone.
|
||||
|
||||
Editing in two places merges rather than one side overwriting the other. Sync
|
||||
is a three-way merge against the last version of the document that device read,
|
||||
which is what lets it tell "you changed this" from "they deleted it": edit
|
||||
different cards on a phone offline and on the desktop, and both survive. When
|
||||
both sides change the *same* field, the other copy's value wins and the change
|
||||
is reported so you can correct it.
|
||||
Editing in two places merges rather than one side overwriting the other. Each
|
||||
device remembers the last version of the library it knows the server had, and
|
||||
merges three ways against it — which is what lets it tell "you changed this"
|
||||
from "they deleted it": edit different cards on a phone offline and on the
|
||||
desktop, and both survive. When both sides change the *same* field, the
|
||||
server's value wins and the change is reported so you can correct it.
|
||||
|
||||
It runs by itself — at launch, on every switch away from and back to the app,
|
||||
and every few minutes while open. **Sync now** in the Info tab forces a pass.
|
||||
Sync runs by itself — at launch, on every switch away from and back to the app,
|
||||
on reconnecting, and every few minutes while open. The top bar shows where it
|
||||
stands (**Synced**, **Offline — changes will sync**, …); **Sync now** in the
|
||||
Info tab forces a pass. Offline, edits simply wait on the device: nothing is
|
||||
lost, and they go out with the next sync that reaches the server.
|
||||
|
||||
Nothing here depends on a particular sync tool. If two devices publish while
|
||||
both are offline, some tools leave a conflict copy behind; mtgc merges any it
|
||||
finds beside the document and clears it once the contents are safely published.
|
||||
The first time a device signs in to an account that already has a library,
|
||||
and the device has one too, it asks whether to **combine** them (nothing from
|
||||
either is lost) or **use the server's library** instead of this device's.
|
||||
|
||||
> **Upgrading from an older build?** Earlier versions had you manage
|
||||
> `library.db` yourself. The Info tab offers **Convert old library** once; the
|
||||
> original file is left untouched, so it doubles as a backup.
|
||||
The Info tab also lists the account's signed-in devices, so a lost phone can be
|
||||
signed out from another one.
|
||||
|
||||
> **Upgrading from folder sync?** Earlier versions kept the library as a JSON
|
||||
> file in a folder your sync tool watched. Your library is already on each
|
||||
> device, so signing in to a server picks up from there; the first device to
|
||||
> sign in uploads it. If one device's copy is out of date, use **Import library
|
||||
> file…** on it to combine in the `library.json` from the synced folder. Builds
|
||||
> older still had you manage `library.db` yourself; the Info tab offers
|
||||
> **Import old library** once for that.
|
||||
|
||||
## Running the sync server
|
||||
|
||||
`mtgc-server` is a single binary with a SQLite database. Run it behind a
|
||||
reverse proxy that provides HTTPS (the Android app refuses plain HTTP outside
|
||||
debug builds). `crates/mtgc-server/deploy/` has a Docker Compose example with
|
||||
Caddy:
|
||||
|
||||
```bash
|
||||
cd crates/mtgc-server/deploy
|
||||
cp .env.example .env # set MTGC_PUBLIC_URL, sign-in options
|
||||
docker compose up -d
|
||||
docker compose exec mtgc-server mtgc-server user add yourname
|
||||
```
|
||||
|
||||
To build the image yourself and push it to your own registry:
|
||||
|
||||
```bash
|
||||
DOCKER_REGISTRY=registry.example.com npm run sync-server:build # → registry.example.com/mtgc/sync-server:latest
|
||||
DOCKER_REGISTRY=registry.example.com npm run sync-server:push
|
||||
```
|
||||
|
||||
Both call `scripts/sync-server-image.sh`. `IMAGE_TAG` overrides the `latest`
|
||||
tag, and `CONTAINER_ENGINE=podman` picks podman when docker is also installed.
|
||||
|
||||
Configuration is all environment variables:
|
||||
|
||||
| Variable | Default | |
|
||||
|---|---|---|
|
||||
| `MTGC_PUBLIC_URL` | *(required)* | The `https://` address users reach the server at, exactly as the proxy serves it |
|
||||
| `MTGC_BIND` | `0.0.0.0:8080` | Listen address |
|
||||
| `MTGC_DB` | `mtgc-server.db` | SQLite database path |
|
||||
| `MTGC_LOCAL_AUTH` | `true` | Username/password accounts |
|
||||
| `MTGC_OIDC_ISSUER` | — | Turns on single sign-on, e.g. `https://auth.example.com/application/o/mtgc/` |
|
||||
| `MTGC_OIDC_CLIENT_ID`, `MTGC_OIDC_CLIENT_SECRET` | — | A confidential client, with redirect URI `${MTGC_PUBLIC_URL}/auth/callback` |
|
||||
| `MTGC_OIDC_NAME` | `single sign-on` | Shown on the button: "Sign in with …" |
|
||||
| `MTGC_OIDC_SCOPES` | `openid email profile` | |
|
||||
| `MTGC_ALLOWED_EMAILS` | — | Comma-separated allow list of verified emails; unset leaves it to the provider |
|
||||
|
||||
With both sign-in methods on, the sign-in page offers both; with only single
|
||||
sign-on, it goes straight to the provider. Each provider identity and each
|
||||
local account is its own user with its own library.
|
||||
|
||||
Local accounts are managed from the command line; there's no sign-up page, so
|
||||
a server on the internet can't be filled with accounts by strangers:
|
||||
|
||||
```bash
|
||||
mtgc-server user add <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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
258
crates/mtgc-core/src/sync/account.rs
Normal file
258
crates/mtgc-core/src/sync/account.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
//! The account side of the sync server: checking an address, finishing a
|
||||
//! sign-in, and managing the account's devices.
|
||||
//!
|
||||
//! The sign-in itself happens in the browser, on the server's own pages (see
|
||||
//! the server's `auth` module); the app only starts it and redeems the
|
||||
//! single-use code it gets back. What the app proves when redeeming is that it
|
||||
//! is the one that started the sign-in — PKCE, RFC 7636 — which is what makes
|
||||
//! it safe for that code to travel through a browser and a URL scheme.
|
||||
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::remote::HttpRemote;
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::http::{agent, USER_AGENT};
|
||||
|
||||
/// What a server says about itself, before anyone is signed in.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ServerInfo {
|
||||
pub server: String,
|
||||
pub api: u32,
|
||||
#[serde(default)]
|
||||
pub local_auth: bool,
|
||||
/// The identity provider's display name, when single sign-on is on.
|
||||
#[serde(default)]
|
||||
pub oidc: Option<String>,
|
||||
}
|
||||
|
||||
/// The newest server API this build speaks.
|
||||
pub const API_VERSION: u32 = 1;
|
||||
|
||||
/// Tidy a server address as typed: trim it, drop trailing slashes, and assume
|
||||
/// `https://` when no scheme is given.
|
||||
pub fn normalize_server_url(input: &str) -> String {
|
||||
let s = input.trim().trim_end_matches('/');
|
||||
if s.contains("://") {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("https://{s}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm `server_url` is an mtgc sync server this build can talk to, before
|
||||
/// sending the user's browser there.
|
||||
pub fn server_info(server_url: &str) -> Result<ServerInfo> {
|
||||
let url = format!("{server_url}/api/info");
|
||||
let info: ServerInfo = agent()
|
||||
.get(&url)
|
||||
.set("User-Agent", USER_AGENT)
|
||||
.set("Accept", "application/json")
|
||||
.call()
|
||||
.map_err(|e| CoreError::Http(format!("couldn't reach {server_url}: {e}")))?
|
||||
.into_json()
|
||||
.map_err(|_| not_a_server(server_url))?;
|
||||
if info.server != "mtgc" {
|
||||
return Err(not_a_server(server_url));
|
||||
}
|
||||
if info.api > API_VERSION {
|
||||
return Err(CoreError::Other(
|
||||
"that sync server is newer than this app — update the app first".into(),
|
||||
));
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
fn not_a_server(url: &str) -> CoreError {
|
||||
CoreError::Other(format!("{url} doesn't look like an mtgc sync server"))
|
||||
}
|
||||
|
||||
/// A PKCE verifier and its S256 challenge.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Pkce {
|
||||
pub verifier: String,
|
||||
pub challenge: String,
|
||||
}
|
||||
|
||||
impl Pkce {
|
||||
pub fn new() -> Self {
|
||||
let verifier = random_token();
|
||||
let challenge = Self::challenge_for(&verifier);
|
||||
Self { verifier, challenge }
|
||||
}
|
||||
|
||||
/// base64url(SHA-256(verifier)), unpadded — RFC 7636 §4.2.
|
||||
pub fn challenge_for(verifier: &str) -> String {
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Pkce {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 256 random bits, base64url: 43 characters, which is also exactly the
|
||||
/// shortest PKCE verifier RFC 7636 allows.
|
||||
pub fn random_token() -> String {
|
||||
let mut buf = [0u8; 32];
|
||||
getrandom::fill(&mut buf).expect("the OS random number generator failed");
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
|
||||
}
|
||||
|
||||
/// The browser address that starts a sign-in.
|
||||
pub fn start_url(
|
||||
server_url: &str,
|
||||
redirect_uri: &str,
|
||||
pkce: &Pkce,
|
||||
state: &str,
|
||||
device_name: &str,
|
||||
) -> String {
|
||||
let query: String = url_encode_pairs(&[
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_challenge", &pkce.challenge),
|
||||
("code_challenge_method", "S256"),
|
||||
("state", state),
|
||||
("device_name", device_name),
|
||||
]);
|
||||
format!("{server_url}/auth/start?{query}")
|
||||
}
|
||||
|
||||
fn url_encode_pairs(pairs: &[(&str, &str)]) -> String {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={}", percent_encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
/// Percent-encode everything but RFC 3986's unreserved characters.
|
||||
fn percent_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(b as char),
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The account a device is signed in to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AccountUser {
|
||||
/// Stable id: what a stored merge base is keyed to.
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub email: String,
|
||||
/// "local" or "oidc".
|
||||
#[serde(default)]
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
/// The result of a completed sign-in.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SignedIn {
|
||||
pub token: String,
|
||||
pub device_id: i64,
|
||||
pub user: AccountUser,
|
||||
}
|
||||
|
||||
/// Trade the code from the browser, with the verifier only this app holds, for
|
||||
/// this device's token.
|
||||
pub fn redeem_code(server_url: &str, code: &str, verifier: &str) -> Result<SignedIn> {
|
||||
let resp = agent()
|
||||
.post(&format!("{server_url}/auth/token"))
|
||||
.set("User-Agent", USER_AGENT)
|
||||
.send_json(serde_json::json!({ "code": code, "code_verifier": verifier }));
|
||||
match resp {
|
||||
Ok(r) => Ok(r.into_json()?),
|
||||
Err(ureq::Error::Status(400, _)) => Err(CoreError::Other(
|
||||
"the sign-in expired before it could be completed — try again".into(),
|
||||
)),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One of an account's signed-in devices.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceInfo {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub created_at: i64,
|
||||
pub last_seen_at: i64,
|
||||
/// Whether it is the device asking.
|
||||
pub current: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Me {
|
||||
pub user: AccountUser,
|
||||
pub devices: Vec<DeviceInfo>,
|
||||
}
|
||||
|
||||
impl HttpRemote {
|
||||
pub fn me(&self) -> Result<Me> {
|
||||
Ok(self.request("GET", "/api/me").call().map_err(super::remote::http_error)?.into_json()?)
|
||||
}
|
||||
|
||||
/// Sign another of the account's devices out.
|
||||
pub fn revoke_device(&self, id: i64) -> Result<()> {
|
||||
self.request("DELETE", &format!("/api/devices/{id}"))
|
||||
.call()
|
||||
.map_err(super::remote::http_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sign this device out on the server, so its token stops working even if
|
||||
/// a copy of it survives somewhere.
|
||||
pub fn logout(&self) -> Result<()> {
|
||||
self.request("POST", "/auth/logout").call().map_err(super::remote::http_error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn server_addresses_are_tidied() {
|
||||
assert_eq!(normalize_server_url(" mtgc.example.com/ "), "https://mtgc.example.com");
|
||||
assert_eq!(normalize_server_url("http://10.0.2.2:8080//"), "http://10.0.2.2:8080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pkce_matches_the_rfc_7636_example() {
|
||||
// Appendix B of the RFC.
|
||||
assert_eq!(
|
||||
Pkce::challenge_for("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
|
||||
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
);
|
||||
let p = Pkce::new();
|
||||
assert_eq!(p.verifier.len(), 43);
|
||||
assert_ne!(p.verifier, Pkce::new().verifier);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_start_url_carries_everything_encoded() {
|
||||
let p = Pkce { verifier: "v".into(), challenge: "c-_".into() };
|
||||
let url = start_url(
|
||||
"https://m.example",
|
||||
"http://127.0.0.1:5000/callback",
|
||||
&p,
|
||||
"s t",
|
||||
"Garrett's phone",
|
||||
);
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://m.example/auth/start?redirect_uri=http%3A%2F%2F127.0.0.1%3A5000%2Fcallback\
|
||||
&code_challenge=c-_&code_challenge_method=S256&state=s%20t&device_name=Garrett%27s%20phone"
|
||||
);
|
||||
}
|
||||
}
|
||||
521
crates/mtgc-core/src/sync/remote.rs
Normal file
521
crates/mtgc-core/src/sync/remote.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
37
crates/mtgc-server/Cargo.toml
Normal file
37
crates/mtgc-server/Cargo.toml
Normal 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 }
|
||||
24
crates/mtgc-server/Dockerfile
Normal file
24
crates/mtgc-server/Dockerfile
Normal 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"]
|
||||
16
crates/mtgc-server/deploy/.env.example
Normal file
16
crates/mtgc-server/deploy/.env.example
Normal 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=
|
||||
5
crates/mtgc-server/deploy/Caddyfile
Normal file
5
crates/mtgc-server/deploy/Caddyfile
Normal 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
|
||||
}
|
||||
29
crates/mtgc-server/deploy/compose.yaml
Normal file
29
crates/mtgc-server/deploy/compose.yaml
Normal 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:
|
||||
186
crates/mtgc-server/src/auth/local.rs
Normal file
186
crates/mtgc-server/src/auth/local.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
304
crates/mtgc-server/src/auth/mod.rs
Normal file
304
crates/mtgc-server/src/auth/mod.rs
Normal 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"))
|
||||
}
|
||||
}
|
||||
266
crates/mtgc-server/src/auth/oidc.rs
Normal file
266
crates/mtgc-server/src/auth/oidc.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
172
crates/mtgc-server/src/config.rs
Normal file
172
crates/mtgc-server/src/config.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
450
crates/mtgc-server/src/db.rs
Normal file
450
crates/mtgc-server/src/db.rs
Normal 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)) = ¤t {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO library_history (user_id, version, doc, saved_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![user_id, v, old, t],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM library_history WHERE user_id = ?1 AND version <= ?2",
|
||||
params![user_id, v - HISTORY_KEEP],
|
||||
)?;
|
||||
}
|
||||
let version = current_version + 1;
|
||||
tx.execute(
|
||||
"INSERT INTO libraries (user_id, version, doc, updated_at) VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
version = excluded.version, doc = excluded.doc, updated_at = excluded.updated_at",
|
||||
params![user_id, version, doc, t],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(PutResult::Stored(version))
|
||||
}
|
||||
|
||||
pub fn history_versions(conn: &Connection, user_id: i64) -> rusqlite::Result<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()
|
||||
}
|
||||
158
crates/mtgc-server/src/lib.rs
Normal file
158
crates/mtgc-server/src/lib.rs
Normal 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()
|
||||
}
|
||||
77
crates/mtgc-server/src/library.rs
Normal file
77
crates/mtgc-server/src/library.rs
Normal 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"))
|
||||
}
|
||||
170
crates/mtgc-server/src/main.rs
Normal file
170
crates/mtgc-server/src/main.rs
Normal 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)
|
||||
}
|
||||
158
crates/mtgc-server/src/pages.rs
Normal file
158
crates/mtgc-server/src/pages.rs
Normal 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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn escaping_neutralises_markup_and_attribute_breakouts() {
|
||||
assert_eq!(esc(r#"<b>"x" & 'y'</b>"#), "<b>"x" & 'y'</b>");
|
||||
}
|
||||
}
|
||||
475
crates/mtgc-server/tests/api.rs
Normal file
475
crates/mtgc-server/tests/api.rs
Normal 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("&", "&");
|
||||
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");
|
||||
}
|
||||
57
crates/mtgc-server/tests/cli.rs
Normal file
57
crates/mtgc-server/tests/cli.rs
Normal 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();
|
||||
}
|
||||
161
crates/mtgc-server/tests/sync_e2e.rs
Normal file
161
crates/mtgc-server/tests/sync_e2e.rs
Normal 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}");
|
||||
}
|
||||
@@ -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
68
scripts/sync-server-image.sh
Executable 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -23,6 +23,13 @@
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"mobile": [
|
||||
{ "scheme": ["mtgc"], "host": "auth", "path": ["/callback"], "appLink": false }
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm"],
|
||||
|
||||
491
src/App.tsx
491
src/App.tsx
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status, SyncStatus } from "./api";
|
||||
import {
|
||||
ColorDistributionBar,
|
||||
formatRelativeTime,
|
||||
HoverPreviewProvider,
|
||||
IconButton,
|
||||
Icons,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
usePersistedState,
|
||||
} from "./components";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { SyncIndicator, SyncPanel } from "./SyncPanel";
|
||||
import { CardEntry } from "./views/CardEntry";
|
||||
import { LibraryBrowser } from "./views/LibraryBrowser";
|
||||
import { CardSearch } from "./views/CardSearch";
|
||||
@@ -39,14 +41,6 @@ const ICON_OPEN_LIBRARY = svg(
|
||||
<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}
|
||||
/>
|
||||
|
||||
@@ -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
424
src/SyncPanel.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
124
src/api.ts
124
src/api.ts
@@ -136,7 +136,8 @@ export interface LibraryFilter {
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
library_path: string;
|
||||
/** Where the library syncs: "user @ host", or this device only. */
|
||||
library_label: string;
|
||||
reference_path: string;
|
||||
reference_ready: boolean;
|
||||
card_count: number;
|
||||
@@ -147,41 +148,74 @@ export interface Status {
|
||||
is_mobile: boolean;
|
||||
}
|
||||
|
||||
/** State of the user's library document — the JSON file they keep in whatever
|
||||
* folder they sync. The SQLite database behind it is internal and never shown. */
|
||||
/** The account a device is signed in to on a sync server. */
|
||||
export interface SyncAccount {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
/** "local" (username/password) or "oidc" (single sign-on). */
|
||||
method: string;
|
||||
}
|
||||
|
||||
/** Library sync with a sync server. The library itself always lives on this
|
||||
* device and works offline; syncing exchanges edits with the other devices. */
|
||||
export interface SyncStatus {
|
||||
/** Whether a document has been chosen. */
|
||||
chosen: boolean;
|
||||
/** Whether it's currently reachable. Distinct from `chosen`: an Android grant
|
||||
* can be revoked and a desktop folder can be deleted or unmounted — both need
|
||||
* re-picking rather than first-time setup. */
|
||||
granted: boolean;
|
||||
label: string | null;
|
||||
/** True where the folder is chosen through the system picker (Android) rather
|
||||
* than by path — the one thing the UI branches on. */
|
||||
picker_based: boolean;
|
||||
/** A pre-document library.db from an older build, waiting to be converted. */
|
||||
signed_in: boolean;
|
||||
/** Kept after signing out, to offer signing back in. */
|
||||
server_url: string | null;
|
||||
account: SyncAccount | null;
|
||||
/** Unix seconds of the last completed sync. */
|
||||
last_synced_at: number | null;
|
||||
/** Why the last sync failed, until one succeeds. */
|
||||
last_error: string | null;
|
||||
/** The first sync found libraries both here and on the server; waiting on
|
||||
* `syncConnect` to say whether to combine them or take the server's. */
|
||||
needs_choice: boolean;
|
||||
/** Edits made here that the server hasn't acknowledged yet. */
|
||||
pending_changes: boolean;
|
||||
/** A sign-in is waiting on the browser. */
|
||||
signing_in: boolean;
|
||||
/** A pre-document library.db from an older build, waiting to be imported. */
|
||||
legacy_library: string | null;
|
||||
/** Which file in the granted folder is the library (Android). Null on
|
||||
* desktop, where the whole path is chosen at once. */
|
||||
document_name: string | null;
|
||||
}
|
||||
|
||||
/** One of the account's signed-in devices. */
|
||||
export interface SyncDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: number;
|
||||
last_seen_at: number;
|
||||
/** Whether it's this device. */
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export type FirstSync = "combine" | "use_server";
|
||||
|
||||
export interface MergeReport {
|
||||
cards_changed: number;
|
||||
decks_changed: number;
|
||||
decks_removed: number;
|
||||
/** Fields both sides changed, where the server's value was kept. */
|
||||
conflicts: string[];
|
||||
}
|
||||
|
||||
/** What an import of a library file brought in. */
|
||||
export interface ImportReport {
|
||||
cards: number;
|
||||
decks: number;
|
||||
report: MergeReport;
|
||||
}
|
||||
|
||||
/** What one sync did. */
|
||||
export interface SyncReport {
|
||||
/** Whether the merge altered the local library. */
|
||||
/** Whether the merge altered this device's library. */
|
||||
changed: boolean;
|
||||
/** Whether the document was rewritten (skipped when we had nothing to add). */
|
||||
/** Whether the server's copy was replaced (skipped when there was nothing to add). */
|
||||
published: boolean;
|
||||
/** Conflict copies absorbed and cleaned up. */
|
||||
absorbed: number;
|
||||
report: {
|
||||
cards_changed: number;
|
||||
decks_changed: number;
|
||||
decks_removed: number;
|
||||
/** Fields both sides changed differently, described in user terms. */
|
||||
conflicts: string[];
|
||||
};
|
||||
/** The first sync is waiting on the user — see `SyncStatus.needs_choice`. */
|
||||
needs_choice: boolean;
|
||||
report: MergeReport;
|
||||
}
|
||||
|
||||
/** A published or upcoming Magic set, from Scryfall. */
|
||||
@@ -299,22 +333,26 @@ export const api = {
|
||||
/** Adopt an already-built reference.db from `path` instead of downloading it. */
|
||||
importReferenceDb: (path: string) => invoke<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 }),
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user