Named libraries: switch between several, each syncing on its own
The app can keep any number of named libraries and switch between them from the top bar or Info → Libraries. Each can sync to the account's other devices or stay on this one (a scratch library to try things in). The sync server now keeps several libraries per user under /api/libraries; a migration makes each existing library the user's default "Main", which /api/library keeps serving for older app builds. Every sync lines the device's libraries up with the account's: new ones elsewhere are downloaded, new ones here created on the server, ones deleted elsewhere flagged rather than thrown away. The existing library keeps its files and merge base, so upgrading asks nothing.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
//! The account side of the sync server: checking an address, finishing a
|
||||
//! sign-in, and managing the account's devices.
|
||||
//! sign-in, and managing the account's devices and libraries.
|
||||
//!
|
||||
//! 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
|
||||
@@ -25,6 +25,10 @@ pub struct ServerInfo {
|
||||
/// The identity provider's display name, when single sign-on is on.
|
||||
#[serde(default)]
|
||||
pub oidc: Option<String>,
|
||||
/// Whether an account can keep several named libraries. Servers from
|
||||
/// before then keep one, at `/api/library`.
|
||||
#[serde(default)]
|
||||
pub libraries: bool,
|
||||
}
|
||||
|
||||
/// The newest server API this build speaks.
|
||||
@@ -190,6 +194,18 @@ pub struct DeviceInfo {
|
||||
pub current: bool,
|
||||
}
|
||||
|
||||
/// One of an account's libraries on the server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RemoteLibrary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// The library `/api/library` serves — the one the account started with.
|
||||
#[serde(default)]
|
||||
pub default: bool,
|
||||
#[serde(default)]
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Me {
|
||||
pub user: AccountUser,
|
||||
@@ -209,6 +225,36 @@ impl HttpRemote {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The account's libraries, the default first.
|
||||
pub fn list_libraries(&self) -> Result<Vec<RemoteLibrary>> {
|
||||
Ok(self.request("GET", "/api/libraries").call().map_err(super::remote::http_error)?.into_json()?)
|
||||
}
|
||||
|
||||
/// Start a new, empty library on the server.
|
||||
pub fn create_library(&self, name: &str) -> Result<RemoteLibrary> {
|
||||
Ok(self
|
||||
.request("POST", "/api/libraries")
|
||||
.send_json(serde_json::json!({ "name": name }))
|
||||
.map_err(super::remote::http_error)?
|
||||
.into_json()?)
|
||||
}
|
||||
|
||||
pub fn rename_library(&self, id: &str, name: &str) -> Result<RemoteLibrary> {
|
||||
Ok(self
|
||||
.request("PATCH", &format!("/api/libraries/{}", super::remote::url_path_segment(id)))
|
||||
.send_json(serde_json::json!({ "name": name }))
|
||||
.map_err(super::remote::http_error)?
|
||||
.into_json()?)
|
||||
}
|
||||
|
||||
/// Delete a library from the server. Devices keep their own copies.
|
||||
pub fn delete_library(&self, id: &str) -> Result<()> {
|
||||
self.request("DELETE", &format!("/api/libraries/{}", super::remote::url_path_segment(id)))
|
||||
.call()
|
||||
.map_err(super::remote::http_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! 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
|
||||
//! The server is deliberately dumb: it keeps versioned documents — each of a
|
||||
//! user's libraries is one — 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
|
||||
@@ -23,8 +23,8 @@
|
||||
//! 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
|
||||
//! stored together with the server, account and library 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.
|
||||
|
||||
@@ -69,16 +69,42 @@ pub struct SyncBase {
|
||||
pub struct AccountBase {
|
||||
pub server_url: String,
|
||||
pub user_id: String,
|
||||
/// The server's id for the library. `None` for the account's only library
|
||||
/// on a server from before named libraries — which became its default.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub library_id: Option<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.
|
||||
/// The base, if it belongs to this server and account's only library (on
|
||||
/// a server without named libraries). `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)
|
||||
self.for_library(server_url, user_id, None, true)
|
||||
}
|
||||
|
||||
/// The base, if it belongs to this server, account and library.
|
||||
///
|
||||
/// `library_id` is `None` when talking to a server without named
|
||||
/// libraries. A base without an id was saved against one of those, and
|
||||
/// holds for the account's default library (`is_default`), which is what
|
||||
/// that one library became when the server was upgraded — and the other
|
||||
/// way round.
|
||||
pub fn for_library(
|
||||
self,
|
||||
server_url: &str,
|
||||
user_id: &str,
|
||||
library_id: Option<&str>,
|
||||
is_default: bool,
|
||||
) -> Option<SyncBase> {
|
||||
let same_library = match (self.library_id.as_deref(), library_id) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
(None, None) => true,
|
||||
_ => is_default,
|
||||
};
|
||||
(self.server_url == server_url && self.user_id == user_id && same_library).then_some(self.base)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,8 +206,14 @@ pub fn first_sync_plan(
|
||||
pub struct HttpRemote {
|
||||
base_url: String,
|
||||
token: String,
|
||||
/// Which of the account's libraries [`Remote`] reads and writes. `None` is
|
||||
/// the default one, at the address servers without named libraries use.
|
||||
library_id: Option<String>,
|
||||
}
|
||||
|
||||
/// The library isn't on the server (any more): another device deleted it.
|
||||
pub const NOT_FOUND: &str = "That library is no longer on the sync server.";
|
||||
|
||||
/// 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.
|
||||
@@ -189,7 +221,25 @@ pub const UNAUTHORIZED: &str = "This device is no longer signed in to the sync s
|
||||
|
||||
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() }
|
||||
Self {
|
||||
base_url: server_url.trim_end_matches('/').to_string(),
|
||||
token: token.to_string(),
|
||||
library_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same server, reading and writing one of the account's named
|
||||
/// libraries.
|
||||
pub fn for_library(server_url: &str, token: &str, library_id: &str) -> Self {
|
||||
Self { library_id: Some(library_id.to_string()), ..Self::new(server_url, token) }
|
||||
}
|
||||
|
||||
/// Where [`Remote`] reads and writes.
|
||||
fn library_path(&self) -> String {
|
||||
match &self.library_id {
|
||||
Some(id) => format!("/api/libraries/{}", url_path_segment(id)),
|
||||
None => "/api/library".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn server_url(&self) -> &str {
|
||||
@@ -208,14 +258,14 @@ impl HttpRemote {
|
||||
|
||||
impl Remote for HttpRemote {
|
||||
fn get(&self) -> Result<RemoteDoc> {
|
||||
let resp = self.request("GET", "/api/library").call().map_err(http_error)?;
|
||||
let resp = self.request("GET", &self.library_path()).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")
|
||||
.request("PUT", &self.library_path())
|
||||
.set("Content-Type", "application/json")
|
||||
.set("If-Match", &format!("\"{if_match}\""))
|
||||
.send_string(&body);
|
||||
@@ -248,9 +298,21 @@ fn parse_remote(text: &str) -> Result<RemoteDoc> {
|
||||
Ok(RemoteDoc { version: wire.version, doc: LibraryDoc::from_json(&wire.doc.to_string())? })
|
||||
}
|
||||
|
||||
/// Ids are the server's random hex, but nothing should be able to steer a
|
||||
/// request elsewhere on the server if one ever isn't.
|
||||
pub(crate) fn url_path_segment(s: &str) -> String {
|
||||
s.bytes()
|
||||
.map(|b| match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (b as char).to_string(),
|
||||
_ => format!("%{b:02X}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn http_error(e: ureq::Error) -> CoreError {
|
||||
match e {
|
||||
ureq::Error::Status(401, _) => CoreError::Other(UNAUTHORIZED.into()),
|
||||
ureq::Error::Status(404, _) => CoreError::Other(NOT_FOUND.into()),
|
||||
ureq::Error::Status(code, resp) => {
|
||||
let detail = resp.into_string().unwrap_or_default();
|
||||
let detail = detail.trim();
|
||||
@@ -465,6 +527,7 @@ mod tests {
|
||||
let stored = AccountBase {
|
||||
server_url: "https://sync.example".into(),
|
||||
user_id: "u1".into(),
|
||||
library_id: None,
|
||||
base: SyncBase { version: 3, doc: doc(&[("a", 1)]) },
|
||||
};
|
||||
|
||||
@@ -473,11 +536,46 @@ mod tests {
|
||||
assert!(stored.for_account("https://other.example", "u1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_base_is_only_used_for_the_library_it_came_from() {
|
||||
let stored = AccountBase {
|
||||
server_url: "https://sync.example".into(),
|
||||
user_id: "u1".into(),
|
||||
library_id: Some("lib1".into()),
|
||||
base: SyncBase { version: 3, doc: doc(&[("a", 1)]) },
|
||||
};
|
||||
let url = "https://sync.example";
|
||||
assert!(stored.clone().for_library(url, "u1", Some("lib1"), false).is_some());
|
||||
assert!(stored.clone().for_library(url, "u1", Some("lib2"), false).is_none());
|
||||
assert!(stored.clone().for_library(url, "u1", Some("lib2"), true).is_none());
|
||||
assert!(stored.clone().for_library(url, "u2", Some("lib1"), false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_base_from_before_named_libraries_holds_for_the_default_one() {
|
||||
// Saved by an older build, which had no library id to save.
|
||||
let text = r#"{"server_url":"https://sync.example","user_id":"u1","version":3,"doc":{"version":1,"cards":{},"decks":{}}}"#;
|
||||
let stored: AccountBase = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(stored.library_id, None);
|
||||
let url = "https://sync.example";
|
||||
assert!(stored.clone().for_library(url, "u1", Some("main"), true).is_some());
|
||||
assert!(stored.clone().for_library(url, "u1", Some("scratch"), false).is_none());
|
||||
assert!(stored.for_account(url, "u1").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_ids_cannot_steer_the_request_path() {
|
||||
let r = HttpRemote::for_library("https://s.example/", "t", "../me?x");
|
||||
assert_eq!(r.library_path(), "/api/libraries/..%2Fme%3Fx");
|
||||
assert_eq!(HttpRemote::new("https://s.example", "t").library_path(), "/api/library");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_account_base_round_trips_through_json() {
|
||||
let stored = AccountBase {
|
||||
server_url: "https://sync.example".into(),
|
||||
user_id: "u1".into(),
|
||||
library_id: None,
|
||||
base: SyncBase { version: 3, doc: doc(&[("a", 1)]) },
|
||||
};
|
||||
let text = serde_json::to_string(&stored).unwrap();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The server's SQLite database: accounts, devices, sign-ins in progress, and
|
||||
//! each user's library.
|
||||
//! each user's libraries.
|
||||
//!
|
||||
//! Everything here is synchronous and runs under the one connection mutex in
|
||||
//! [`crate::AppState`]. Every statement is a quick indexed lookup or a
|
||||
@@ -69,6 +69,40 @@ const MIGRATIONS: &[&str] = &[
|
||||
saved_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, version)
|
||||
);",
|
||||
// 2: named libraries. A user can have several; the one they already had
|
||||
// becomes their default, which `/api/library` keeps serving. Rebuilt
|
||||
// rather than altered: SQLite can't change a primary key in place.
|
||||
"ALTER TABLE libraries RENAME TO libraries_v1;
|
||||
ALTER TABLE library_history RENAME TO library_history_v1;
|
||||
CREATE TABLE libraries (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
public_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
-- 0, with no doc, until the first write.
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
doc TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX libraries_name ON libraries (user_id, name COLLATE NOCASE);
|
||||
CREATE UNIQUE INDEX libraries_default ON libraries (user_id) WHERE is_default;
|
||||
CREATE TABLE library_history (
|
||||
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
doc TEXT NOT NULL,
|
||||
saved_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (library_id, version)
|
||||
);
|
||||
INSERT INTO libraries (user_id, public_id, name, is_default, version, doc, created_at, updated_at)
|
||||
SELECT user_id, lower(hex(randomblob(16))), 'Main', 1, version, doc, updated_at, updated_at
|
||||
FROM libraries_v1;
|
||||
INSERT INTO library_history (library_id, version, doc, saved_at)
|
||||
SELECT l.id, h.version, h.doc, h.saved_at
|
||||
FROM library_history_v1 h JOIN libraries l ON l.user_id = h.user_id AND l.is_default;
|
||||
DROP TABLE library_history_v1;
|
||||
DROP TABLE libraries_v1;",
|
||||
];
|
||||
|
||||
/// How many superseded versions of each library are kept. A safety net for
|
||||
@@ -195,19 +229,21 @@ pub fn set_local_password(
|
||||
)? > 0)
|
||||
}
|
||||
|
||||
/// Delete a local account along with its devices and library. Returns whether
|
||||
/// Delete a local account along with its devices and libraries. 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)>> {
|
||||
/// Every account, for `mtgc-server user list`: (user, device count, library
|
||||
/// count).
|
||||
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<(User, i64, i64)>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {USER_COLS}, (SELECT COUNT(*) FROM devices d WHERE d.user_id = users.id)
|
||||
"SELECT {USER_COLS}, (SELECT COUNT(*) FROM devices d WHERE d.user_id = users.id),
|
||||
(SELECT COUNT(*) FROM libraries l WHERE l.user_id = users.id)
|
||||
FROM users ORDER BY iss, sub"
|
||||
))?;
|
||||
let rows = stmt.query_map([], |r| Ok((user_row(r)?, r.get(6)?)))?;
|
||||
let rows = stmt.query_map([], |r| Ok((user_row(r)?, r.get(6)?, r.get(7)?)))?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
@@ -388,63 +424,275 @@ pub fn take_auth_code(
|
||||
|
||||
// --- 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)>> {
|
||||
/// One of a user's libraries, without its document.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Library {
|
||||
pub id: i64,
|
||||
/// What clients see as the library's id. Random, like `users.public_id`.
|
||||
pub public_id: String,
|
||||
pub name: String,
|
||||
/// The library `/api/library` serves: the one every account started with,
|
||||
/// and the one app builds from before named libraries sync.
|
||||
pub is_default: bool,
|
||||
/// 0 until the first document is stored.
|
||||
pub version: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
const LIBRARY_COLS: &str = "id, public_id, name, is_default, version, updated_at";
|
||||
|
||||
fn library_row(r: &rusqlite::Row) -> rusqlite::Result<Library> {
|
||||
Ok(Library {
|
||||
id: r.get(0)?,
|
||||
public_id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
is_default: r.get(3)?,
|
||||
version: r.get(4)?,
|
||||
updated_at: r.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Why a library couldn't be created, renamed or deleted.
|
||||
#[derive(Debug)]
|
||||
pub enum LibraryError {
|
||||
/// The user already has a library by that name (ignoring case).
|
||||
NameTaken,
|
||||
/// The default library can't be deleted.
|
||||
IsDefault,
|
||||
Db(rusqlite::Error),
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for LibraryError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
Self::Db(e)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unique_violation(e: &rusqlite::Error) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
rusqlite::Error::SqliteFailure(f, _) if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
|
||||
)
|
||||
}
|
||||
|
||||
/// A user's libraries, the default first. Creates the default if the user
|
||||
/// has none yet, so every account always has somewhere to sync to.
|
||||
pub fn list_libraries(conn: &Connection, user_id: i64) -> rusqlite::Result<Vec<Library>> {
|
||||
default_library(conn, user_id)?;
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {LIBRARY_COLS} FROM libraries WHERE user_id = ?1
|
||||
ORDER BY is_default DESC, name COLLATE NOCASE, id"
|
||||
))?;
|
||||
let rows = stmt.query_map([user_id], library_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
/// The user's default library, created (empty, at version 0) if needed.
|
||||
pub fn default_library(conn: &Connection, user_id: i64) -> rusqlite::Result<Library> {
|
||||
let found = conn
|
||||
.query_row(
|
||||
&format!("SELECT {LIBRARY_COLS} FROM libraries WHERE user_id = ?1 AND is_default"),
|
||||
[user_id],
|
||||
library_row,
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(lib) = found {
|
||||
return Ok(lib);
|
||||
}
|
||||
// "Main", unless the user already took that name for another library.
|
||||
let mut name = DEFAULT_LIBRARY_NAME.to_string();
|
||||
let mut n = 1;
|
||||
loop {
|
||||
match insert_library(conn, user_id, &name, true) {
|
||||
Err(LibraryError::NameTaken) => {
|
||||
n += 1;
|
||||
name = format!("{DEFAULT_LIBRARY_NAME} {n}");
|
||||
}
|
||||
Err(LibraryError::Db(e)) => return Err(e),
|
||||
Err(LibraryError::IsDefault) => unreachable!(),
|
||||
Ok(lib) => return Ok(lib),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the default library is called until someone renames it.
|
||||
pub const DEFAULT_LIBRARY_NAME: &str = "Main";
|
||||
|
||||
fn insert_library(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
name: &str,
|
||||
is_default: bool,
|
||||
) -> Result<Library, LibraryError> {
|
||||
let t = now();
|
||||
let inserted = conn.execute(
|
||||
"INSERT INTO libraries (user_id, public_id, name, is_default, version, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, 0, ?5, ?5)",
|
||||
params![user_id, crate::random_hex(16), name, is_default, t],
|
||||
);
|
||||
match inserted {
|
||||
Err(e) if is_unique_violation(&e) => return Err(LibraryError::NameTaken),
|
||||
r => r?,
|
||||
};
|
||||
Ok(conn.query_row(
|
||||
&format!("SELECT {LIBRARY_COLS} FROM libraries WHERE id = ?1"),
|
||||
[conn.last_insert_rowid()],
|
||||
library_row,
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Create a new, empty library for a user.
|
||||
pub fn create_library(conn: &Connection, user_id: i64, name: &str) -> Result<Library, LibraryError> {
|
||||
// The default comes first, so a brand-new account creating its second
|
||||
// library doesn't end up with that one as its only library.
|
||||
default_library(conn, user_id)?;
|
||||
insert_library(conn, user_id, name, false)
|
||||
}
|
||||
|
||||
/// One of a user's libraries by its public id. Scoped to the user, so nobody
|
||||
/// can reach someone else's.
|
||||
pub fn library_for(conn: &Connection, user_id: i64, public_id: &str) -> rusqlite::Result<Option<Library>> {
|
||||
conn.query_row(
|
||||
"SELECT version, doc FROM libraries WHERE user_id = ?1",
|
||||
[user_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
&format!("SELECT {LIBRARY_COLS} FROM libraries WHERE user_id = ?1 AND public_id = ?2"),
|
||||
params![user_id, public_id],
|
||||
library_row,
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
pub fn rename_library(conn: &Connection, library_id: i64, name: &str) -> Result<(), LibraryError> {
|
||||
match conn.execute("UPDATE libraries SET name = ?2 WHERE id = ?1", params![library_id, name]) {
|
||||
Err(e) if is_unique_violation(&e) => Err(LibraryError::NameTaken),
|
||||
r => r.map(|_| ()).map_err(Into::into),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a library and its history. The default library stays.
|
||||
pub fn delete_library(conn: &Connection, library: &Library) -> Result<(), LibraryError> {
|
||||
if library.is_default {
|
||||
return Err(LibraryError::IsDefault);
|
||||
}
|
||||
conn.execute("DELETE FROM libraries WHERE id = ?1", [library.id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A library's document: (version, document JSON). No document means nothing
|
||||
/// has been stored yet.
|
||||
pub fn get_library(conn: &Connection, library_id: i64) -> rusqlite::Result<(i64, Option<String>)> {
|
||||
conn.query_row(
|
||||
"SELECT version, doc FROM libraries WHERE id = ?1",
|
||||
[library_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
}
|
||||
|
||||
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`.
|
||||
/// Replace a library's document, but only if it is still at `if_match`.
|
||||
pub fn put_library(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
library_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);
|
||||
let (current_version, current) = get_library(&tx, library_id)?;
|
||||
if current_version != if_match {
|
||||
return Ok(PutResult::Conflict(current_version, current.map(|(_, d)| d)));
|
||||
return Ok(PutResult::Conflict(current_version, current));
|
||||
}
|
||||
|
||||
let t = now();
|
||||
if let Some((v, old)) = ¤t {
|
||||
if let Some(old) = ¤t {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO library_history (user_id, version, doc, saved_at)
|
||||
"INSERT OR REPLACE INTO library_history (library_id, version, doc, saved_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![user_id, v, old, t],
|
||||
params![library_id, current_version, old, t],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM library_history WHERE user_id = ?1 AND version <= ?2",
|
||||
params![user_id, v - HISTORY_KEEP],
|
||||
"DELETE FROM library_history WHERE library_id = ?1 AND version <= ?2",
|
||||
params![library_id, current_version - 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],
|
||||
"UPDATE libraries SET version = ?2, doc = ?3, updated_at = ?4 WHERE id = ?1",
|
||||
params![library_id, version, doc, t],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(PutResult::Stored(version))
|
||||
}
|
||||
|
||||
pub fn history_versions(conn: &Connection, user_id: i64) -> rusqlite::Result<Vec<i64>> {
|
||||
pub fn history_versions(conn: &Connection, library_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))?;
|
||||
.prepare("SELECT version FROM library_history WHERE library_id = ?1 ORDER BY version")?;
|
||||
let rows = stmt.query_map([library_id], |r| r.get(0))?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_library_a_user_already_had_becomes_their_default() {
|
||||
// A database as the first release left it: one library per user.
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||
conn.execute_batch(MIGRATIONS[0]).unwrap();
|
||||
conn.pragma_update(None, "user_version", 1).unwrap();
|
||||
conn.execute_batch(
|
||||
"INSERT INTO users (id, public_id, iss, sub, created_at) VALUES (1, 'u1', 'local', 'alice', 0);
|
||||
INSERT INTO users (id, public_id, iss, sub, created_at) VALUES (2, 'u2', 'local', 'bob', 0);
|
||||
INSERT INTO libraries (user_id, version, doc, updated_at) VALUES (1, 7, '{\"a\":1}', 100);
|
||||
INSERT INTO library_history (user_id, version, doc, saved_at) VALUES (1, 6, '{\"old\":1}', 90);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let conn = init(conn).unwrap();
|
||||
|
||||
let libs = list_libraries(&conn, 1).unwrap();
|
||||
assert_eq!(libs.len(), 1);
|
||||
let main = &libs[0];
|
||||
assert!(main.is_default);
|
||||
assert_eq!(main.name, DEFAULT_LIBRARY_NAME);
|
||||
assert_eq!(main.public_id.len(), 32);
|
||||
assert_eq!(get_library(&conn, main.id).unwrap(), (7, Some("{\"a\":1}".to_string())));
|
||||
assert_eq!(history_versions(&conn, main.id).unwrap(), vec![6]);
|
||||
|
||||
// Bob never stored anything: he gets an empty default when he asks.
|
||||
let bobs = default_library(&conn, 2).unwrap();
|
||||
assert_eq!(get_library(&conn, bobs.id).unwrap(), (0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_unique_per_user_ignoring_case() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let a = create_local_user(&conn, "alice", "", "h").unwrap();
|
||||
let b = create_local_user(&conn, "bob", "", "h").unwrap();
|
||||
create_library(&conn, a.id, "Scratch").unwrap();
|
||||
assert!(matches!(create_library(&conn, a.id, "scratch"), Err(LibraryError::NameTaken)));
|
||||
create_library(&conn, b.id, "Scratch").unwrap();
|
||||
|
||||
let main = default_library(&conn, a.id).unwrap();
|
||||
assert!(matches!(rename_library(&conn, main.id, "SCRATCH"), Err(LibraryError::NameTaken)));
|
||||
assert!(matches!(delete_library(&conn, &main), Err(LibraryError::IsDefault)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_user_deletes_their_libraries_and_history() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let a = create_local_user(&conn, "alice", "", "h").unwrap();
|
||||
let lib = create_library(&conn, a.id, "Scratch").unwrap();
|
||||
put_library(&conn, lib.id, 0, "{}").unwrap();
|
||||
put_library(&conn, lib.id, 1, "{}").unwrap();
|
||||
assert!(delete_local_user(&conn, "alice").unwrap());
|
||||
let left: i64 = conn
|
||||
.query_row("SELECT (SELECT COUNT(*) FROM libraries) + (SELECT COUNT(*) FROM library_history)", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(left, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! 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
|
||||
//! The server is deliberately dumb about libraries: it stores versioned
|
||||
//! documents — a user can have several, by name — 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.
|
||||
//!
|
||||
@@ -71,7 +71,12 @@ 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/library", get(library::get_default).put(library::put_default))
|
||||
.route("/api/libraries", get(library::list).post(library::create))
|
||||
.route(
|
||||
"/api/libraries/{id}",
|
||||
get(library::get_doc).put(library::put_doc).patch(library::rename).delete(library::remove),
|
||||
)
|
||||
.route("/api/me", get(auth::me))
|
||||
.route("/api/devices/{id}", delete(auth::revoke_device))
|
||||
.route("/auth/start", get(auth::start))
|
||||
@@ -91,6 +96,9 @@ async fn info(axum::extract::State(state): axum::extract::State<AppState>) -> Js
|
||||
Json(serde_json::json!({
|
||||
"server": "mtgc",
|
||||
"api": 1,
|
||||
// Named libraries at /api/libraries. Advertised rather than bumping
|
||||
// `api`, which older apps take as "this server is too new for me".
|
||||
"libraries": true,
|
||||
"local_auth": c.local_auth,
|
||||
"oidc": c.oidc.as_ref().map(|o| o.display_name.clone()),
|
||||
}))
|
||||
|
||||
@@ -1,62 +1,184 @@
|
||||
//! The one resource: the signed-in user's library document.
|
||||
//! The signed-in user's libraries.
|
||||
//!
|
||||
//! `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.
|
||||
//! `GET /api/libraries` lists them and `POST` creates one; each is then a
|
||||
//! versioned document at `/api/libraries/{id}`. `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.
|
||||
//!
|
||||
//! `/api/library` is the user's default library under its old address, for
|
||||
//! app builds from before there could be more than one.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use mtgc_core::sync::LibraryDoc;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::auth::Authed;
|
||||
use crate::db::{self, PutResult};
|
||||
use crate::db::{self, Library, LibraryError, 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())
|
||||
/// Longest library name accepted, in characters.
|
||||
const MAX_NAME_CHARS: usize = 100;
|
||||
|
||||
pub async fn list(State(state): State<AppState>, auth: Authed) -> Result<Response, ApiError> {
|
||||
let libs = db::list_libraries(&state.db(), auth.user.id)?;
|
||||
Ok(Json(libs.iter().map(describe).collect::<Vec<_>>()).into_response())
|
||||
}
|
||||
|
||||
pub async fn put_library(
|
||||
#[derive(Deserialize)]
|
||||
pub struct NameBody {
|
||||
name: String,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
auth: Authed,
|
||||
Json(body): Json<NameBody>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let name = valid_name(&body.name)?;
|
||||
let lib = db::create_library(&state.db(), auth.user.id, &name).map_err(library_error)?;
|
||||
Ok((StatusCode::CREATED, Json(describe(&lib))).into_response())
|
||||
}
|
||||
|
||||
pub async fn rename(
|
||||
State(state): State<AppState>,
|
||||
auth: Authed,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<NameBody>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let name = valid_name(&body.name)?;
|
||||
let conn = state.db();
|
||||
let lib = find(&conn, &auth, &id)?;
|
||||
db::rename_library(&conn, lib.id, &name).map_err(library_error)?;
|
||||
Ok(Json(describe(&Library { name, ..lib })).into_response())
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<AppState>,
|
||||
auth: Authed,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let conn = state.db();
|
||||
let lib = find(&conn, &auth, &id)?;
|
||||
db::delete_library(&conn, &lib).map_err(library_error)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn get_doc(
|
||||
State(state): State<AppState>,
|
||||
auth: Authed,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let conn = state.db();
|
||||
let lib = find(&conn, &auth, &id)?;
|
||||
Ok(Json(wire(db::get_library(&conn, lib.id)?)?).into_response())
|
||||
}
|
||||
|
||||
pub async fn put_doc(
|
||||
State(state): State<AppState>,
|
||||
auth: Authed,
|
||||
Path(id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> Result<Response, ApiError> {
|
||||
let (if_match, text) = parse_put(&headers, &body)?;
|
||||
let conn = state.db();
|
||||
let lib = find(&conn, &auth, &id)?;
|
||||
stored(db::put_library(&conn, lib.id, if_match, &text)?)
|
||||
}
|
||||
|
||||
/// `GET /api/library`: the default library, for older app builds.
|
||||
pub async fn get_default(State(state): State<AppState>, auth: Authed) -> Result<Response, ApiError> {
|
||||
let conn = state.db();
|
||||
let lib = db::default_library(&conn, auth.user.id)?;
|
||||
Ok(Json(wire(db::get_library(&conn, lib.id)?)?).into_response())
|
||||
}
|
||||
|
||||
/// `PUT /api/library`: the default library, for older app builds.
|
||||
pub async fn put_default(
|
||||
State(state): State<AppState>,
|
||||
auth: Authed,
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> Result<Response, ApiError> {
|
||||
let if_match = if_match(&headers)?;
|
||||
let (if_match, text) = parse_put(&headers, &body)?;
|
||||
let conn = state.db();
|
||||
let lib = db::default_library(&conn, auth.user.id)?;
|
||||
stored(db::put_library(&conn, lib.id, if_match, &text)?)
|
||||
}
|
||||
|
||||
fn describe(lib: &Library) -> Value {
|
||||
json!({
|
||||
"id": lib.public_id,
|
||||
"name": lib.name,
|
||||
"default": lib.is_default,
|
||||
"version": lib.version,
|
||||
"updated_at": lib.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn find(conn: &rusqlite::Connection, auth: &Authed, id: &str) -> Result<Library, ApiError> {
|
||||
db::library_for(conn, auth.user.id, id)?
|
||||
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "no such library"))
|
||||
}
|
||||
|
||||
fn valid_name(name: &str) -> Result<String, ApiError> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(ApiError::new(StatusCode::BAD_REQUEST, "a library needs a name"));
|
||||
}
|
||||
if name.chars().count() > MAX_NAME_CHARS {
|
||||
return Err(ApiError::new(StatusCode::BAD_REQUEST, "that name is too long"));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
fn library_error(e: LibraryError) -> ApiError {
|
||||
match e {
|
||||
LibraryError::NameTaken => ApiError::new(StatusCode::CONFLICT, "you already have a library by that name"),
|
||||
LibraryError::IsDefault => ApiError::new(StatusCode::CONFLICT, "the default library can't be deleted"),
|
||||
LibraryError::Db(e) => e.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The version a write replaces, and the document normalised for storing.
|
||||
fn parse_put(headers: &HeaderMap, body: &str) -> Result<(i64, String), 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)
|
||||
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()))?;
|
||||
Ok((if_match, text))
|
||||
}
|
||||
|
||||
let result = db::put_library(&state.db(), auth.user.id, if_match, &text)?;
|
||||
fn stored(result: PutResult) -> Result<Response, ApiError> {
|
||||
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()
|
||||
(StatusCode::PRECONDITION_FAILED, Json(wire((version, doc))?)).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> {
|
||||
/// `{version, doc}`, with an empty library for one nothing was stored in yet.
|
||||
fn wire((version, stored): (i64, Option<String>)) -> Result<Value, ApiError> {
|
||||
Ok(match stored {
|
||||
Some((version, text)) => {
|
||||
Some(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() }),
|
||||
None => json!({ "version": version, "doc": LibraryDoc::default() }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ fn user(args: &[String]) -> anyhow::Result<()> {
|
||||
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). \
|
||||
the libraries stored on this server (devices keep their own copies). \
|
||||
Re-run with --yes to confirm."
|
||||
);
|
||||
}
|
||||
@@ -167,10 +167,13 @@ fn user(args: &[String]) -> anyhow::Result<()> {
|
||||
if users.is_empty() {
|
||||
println!("No accounts yet. Add one with `mtgc-server user add <name>`.");
|
||||
}
|
||||
for (u, devices) in users {
|
||||
for (u, devices, libraries) 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);
|
||||
println!(
|
||||
"{:<24} {:<8} devices: {:<3} libraries: {:<3} {}",
|
||||
u.sub, kind, devices, libraries, who
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => bail!("{USAGE}"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The server driven through its router in-process: sign-in end to end, the
|
||||
//! library's compare-and-swap, and device revocation.
|
||||
//! libraries and their compare-and-swap, and device revocation.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Request, Response, StatusCode};
|
||||
@@ -110,8 +110,12 @@ impl Server {
|
||||
}
|
||||
|
||||
async fn get_library(&self, token: &str) -> Response<Body> {
|
||||
self.get_at("/api/library", token).await
|
||||
}
|
||||
|
||||
async fn get_at(&self, uri: &str, token: &str) -> Response<Body> {
|
||||
self.send(
|
||||
Request::get("/api/library")
|
||||
Request::get(uri)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
@@ -120,7 +124,11 @@ impl Server {
|
||||
}
|
||||
|
||||
async fn put_library(&self, token: &str, if_match: Option<&str>, doc: Value) -> Response<Body> {
|
||||
let mut req = Request::put("/api/library")
|
||||
self.put_at("/api/library", token, if_match, doc).await
|
||||
}
|
||||
|
||||
async fn put_at(&self, uri: &str, token: &str, if_match: Option<&str>, doc: Value) -> Response<Body> {
|
||||
let mut req = Request::put(uri)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(v) = if_match {
|
||||
@@ -130,6 +138,16 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// An authenticated JSON request.
|
||||
fn authed(method: &str, uri: &str, token: &str, body: Option<Value>) -> Request<Body> {
|
||||
let req = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
req.body(body.map(|b| Body::from(b.to_string())).unwrap_or_else(Body::empty)).unwrap()
|
||||
}
|
||||
|
||||
fn get(uri: &str) -> Request<Body> {
|
||||
Request::get(uri).body(Body::empty()).unwrap()
|
||||
}
|
||||
@@ -341,6 +359,7 @@ async fn the_server_describes_itself() {
|
||||
assert_eq!(info["server"], "mtgc");
|
||||
assert_eq!(info["local_auth"], true);
|
||||
assert_eq!(info["oidc"], Value::Null);
|
||||
assert_eq!(info["libraries"], true);
|
||||
}
|
||||
|
||||
// --- devices ------------------------------------------------------------------
|
||||
@@ -469,7 +488,116 @@ async fn old_versions_are_kept_up_to_a_limit() {
|
||||
}
|
||||
|
||||
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();
|
||||
let main = db::default_library(&s.state.db(), user_id).unwrap();
|
||||
let kept = db::history_versions(&s.state.db(), main.id).unwrap();
|
||||
assert_eq!(kept.len() as i64, db::HISTORY_KEEP);
|
||||
assert_eq!(*kept.last().unwrap(), total - 1, "the newest superseded version is kept");
|
||||
}
|
||||
|
||||
// --- named libraries ------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_account_starts_with_a_default_library() {
|
||||
let s = Server::local();
|
||||
let t = s.sign_in("alice", "correct horse battery").await;
|
||||
let list = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await).await;
|
||||
let list = list.as_array().unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0]["name"], "Main");
|
||||
assert_eq!(list[0]["default"], true);
|
||||
assert_eq!(list[0]["version"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn libraries_can_be_created_renamed_and_deleted() {
|
||||
let s = Server::local();
|
||||
let t = s.sign_in("alice", "correct horse battery").await;
|
||||
|
||||
let resp = s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": " Scratch " })))).await;
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let scratch = body_json(resp).await;
|
||||
assert_eq!(scratch["name"], "Scratch");
|
||||
assert_eq!(scratch["default"], false);
|
||||
let id = scratch["id"].as_str().unwrap().to_string();
|
||||
|
||||
let dup = s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": "scratch" })))).await;
|
||||
assert_eq!(dup.status(), StatusCode::CONFLICT);
|
||||
let blank = s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": " " })))).await;
|
||||
assert_eq!(blank.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let resp = s.send(authed("PATCH", &format!("/api/libraries/{id}"), &t, Some(json!({ "name": "Testing" })))).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let names: Vec<Value> = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await)
|
||||
.await
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|l| l["name"].clone())
|
||||
.collect();
|
||||
assert_eq!(names, vec![json!("Main"), json!("Testing")]);
|
||||
|
||||
let main_id = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await).await[0]["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let resp = s.send(authed("DELETE", &format!("/api/libraries/{main_id}"), &t, None)).await;
|
||||
assert_eq!(resp.status(), StatusCode::CONFLICT, "the default library stays");
|
||||
|
||||
let resp = s.send(authed("DELETE", &format!("/api/libraries/{id}"), &t, None)).await;
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
let resp = s.get_at(&format!("/api/libraries/{id}"), &t).await;
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn each_library_has_its_own_versions() {
|
||||
let s = Server::local();
|
||||
let t = s.sign_in("alice", "correct horse battery").await;
|
||||
let scratch = body_json(s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": "Scratch" })))).await).await;
|
||||
let uri = format!("/api/libraries/{}", scratch["id"].as_str().unwrap());
|
||||
|
||||
assert_eq!(s.put_library(&t, Some("0"), library(&[("a", 1)])).await.status(), StatusCode::OK);
|
||||
assert_eq!(s.put_library(&t, Some("1"), library(&[("a", 2)])).await.status(), StatusCode::OK);
|
||||
|
||||
// Scratch is still at 0, whatever the default library is at.
|
||||
assert_eq!(s.put_at(&uri, &t, Some("2"), library(&[("x", 1)])).await.status(), StatusCode::PRECONDITION_FAILED);
|
||||
assert_eq!(s.put_at(&uri, &t, Some("0"), library(&[("x", 1)])).await.status(), StatusCode::OK);
|
||||
assert_eq!(s.put_at(&uri, &t, None, library(&[("x", 1)])).await.status(), StatusCode::PRECONDITION_REQUIRED);
|
||||
|
||||
let got = body_json(s.get_at(&uri, &t).await).await;
|
||||
assert_eq!(got["version"], 1);
|
||||
assert_eq!(got["doc"]["cards"], json!({ "x": { "quantity": 1 } }));
|
||||
let main = body_json(s.get_library(&t).await).await;
|
||||
assert_eq!(main["version"], 2);
|
||||
assert_eq!(main["doc"]["cards"]["a"]["quantity"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_old_address_is_the_default_library() {
|
||||
let s = Server::local();
|
||||
let t = s.sign_in("alice", "correct horse battery").await;
|
||||
s.put_library(&t, Some("0"), library(&[("a", 1)])).await;
|
||||
|
||||
let list = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await).await;
|
||||
let main = &list.as_array().unwrap()[0];
|
||||
assert_eq!(main["version"], 1);
|
||||
let got = body_json(s.get_at(&format!("/api/libraries/{}", main["id"].as_str().unwrap()), &t).await).await;
|
||||
assert_eq!(got["doc"]["cards"]["a"]["quantity"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nobody_can_reach_someone_elses_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;
|
||||
let scratch = body_json(s.send(authed("POST", "/api/libraries", &alice, Some(json!({ "name": "Scratch" })))).await).await;
|
||||
let uri = format!("/api/libraries/{}", scratch["id"].as_str().unwrap());
|
||||
|
||||
assert_eq!(s.get_at(&uri, &bob).await.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(s.put_at(&uri, &bob, Some("0"), library(&[("a", 1)])).await.status(), StatusCode::NOT_FOUND);
|
||||
let rename = s.send(authed("PATCH", &uri, &bob, Some(json!({ "name": "Mine" })))).await;
|
||||
assert_eq!(rename.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(s.send(authed("DELETE", &uri, &bob, None)).await.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(s.get_at(&uri, &alice).await.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
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::remote::{sync_cycle, HttpRemote, SyncBase, NOT_FOUND, UNAUTHORIZED};
|
||||
use mtgc_core::sync::{apply_doc, merge_into, printing_key, read_doc, CardEntry};
|
||||
use mtgc_server::auth::local::hash_password;
|
||||
use mtgc_server::{db, router, AppState, Config};
|
||||
@@ -64,6 +64,16 @@ impl Device {
|
||||
}
|
||||
}
|
||||
|
||||
/// A second working copy on the same device, for one of the account's
|
||||
/// named libraries.
|
||||
fn for_library(dir: &Path, name: &str, server: &str, token: &str, library_id: &str) -> Self {
|
||||
Self {
|
||||
db: dir.join(format!("{name}.db")),
|
||||
base: SyncBase::default(),
|
||||
remote: HttpRemote::for_library(server, token, library_id),
|
||||
}
|
||||
}
|
||||
|
||||
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| {
|
||||
@@ -197,3 +207,53 @@ fn an_address_that_is_not_an_mtgc_server_is_rejected() {
|
||||
let err = server_info(&format!("http://{addr}")).unwrap_err();
|
||||
assert!(err.to_string().contains("doesn't look like an mtgc sync server"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_library_syncs_without_touching_the_default_one() {
|
||||
let (server, state) = spawn_server();
|
||||
let hash = hash_password("correct horse battery").unwrap();
|
||||
db::create_local_user(&state.db(), "carol", "Carol", &hash).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("mtgc-e2e-named-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
assert!(server_info(&server).unwrap().libraries);
|
||||
let phone_login = sign_in(&server, "carol", "correct horse battery");
|
||||
let desktop_login = sign_in(&server, "carol", "correct horse battery");
|
||||
|
||||
let mut phone_main = Device::new(&dir, "phone-main", &server, &phone_login.token);
|
||||
phone_main.set("a", 1);
|
||||
phone_main.sync().unwrap();
|
||||
|
||||
// The phone starts a scratch library; the desktop finds it in the list.
|
||||
let scratch = phone_main.remote.create_library("Scratch").unwrap();
|
||||
let mut phone_scratch = Device::for_library(&dir, "phone-scratch", &server, &phone_login.token, &scratch.id);
|
||||
phone_scratch.set("x", 5);
|
||||
assert!(phone_scratch.sync().unwrap().published);
|
||||
|
||||
let desktop_account = HttpRemote::new(&server, &desktop_login.token);
|
||||
let listed = desktop_account.list_libraries().unwrap();
|
||||
assert_eq!(listed.iter().map(|l| (l.name.as_str(), l.default)).collect::<Vec<_>>(), [("Main", true), ("Scratch", false)]);
|
||||
let mut desktop_scratch = Device::for_library(&dir, "desktop-scratch", &server, &desktop_login.token, &listed[1].id);
|
||||
desktop_scratch.sync().unwrap();
|
||||
assert_eq!(desktop_scratch.qty("x"), Some(5));
|
||||
assert_eq!(desktop_scratch.qty("a"), None, "nothing from Main");
|
||||
|
||||
desktop_scratch.set("x", 6);
|
||||
desktop_scratch.sync().unwrap();
|
||||
phone_scratch.sync().unwrap();
|
||||
assert_eq!(phone_scratch.qty("x"), Some(6));
|
||||
|
||||
let mut desktop_main = Device::new(&dir, "desktop-main", &server, &desktop_login.token);
|
||||
desktop_main.sync().unwrap();
|
||||
assert_eq!(desktop_main.qty("a"), Some(1));
|
||||
assert_eq!(desktop_main.qty("x"), None, "nothing from Scratch");
|
||||
|
||||
// Deleted from one device: the other's next sync says so distinctly.
|
||||
desktop_account.delete_library(&scratch.id).unwrap();
|
||||
let err = phone_scratch.sync().unwrap_err();
|
||||
assert_eq!(err.to_string(), NOT_FOUND);
|
||||
assert_eq!(phone_scratch.qty("x"), Some(6), "the local copy is kept");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user