From 1ce93d883bd49137efe799eb2f692df079841910 Mon Sep 17 00:00:00 2001 From: nnduc Date: Tue, 27 Oct 2026 23:49:59 +0700 Subject: [PATCH] omemo keyring --- Cargo.toml | 2 +- README.md | 20 +- data/movim.sqlite | Bin 151552 -> 159744 bytes data/movim.sqlite-shm | Bin 32768 -> 0 bytes data/movim.sqlite-wal | Bin 1203072 -> 0 bytes frontend/widgets/Account/account.js | 14 +- frontend/widgets/ChatOmemo/chatomemo.js | 20 +- frontend/widgets/ChatOmemo/chatomemo_db.js | 35 + src/db.rs | 1207 ++++++++++------- src/main.rs | 37 + src/migrate_postgres.rs | 327 +++++ src/widgets/chats.rs | 17 +- .../__pycache__/browser_edit.cpython-313.pyc | Bin 0 -> 8364 bytes tests/e2e/browser_edit.py | 3 +- tests/e2e/browser_omemo_history.py | 68 + tests/e2e/browser_omemo_restore.py | 58 + 16 files changed, 1327 insertions(+), 481 deletions(-) delete mode 100644 data/movim.sqlite-shm delete mode 100644 data/movim.sqlite-wal create mode 100644 src/migrate_postgres.rs create mode 100644 tests/e2e/__pycache__/browser_edit.cpython-313.pyc create mode 100644 tests/e2e/browser_omemo_history.py create mode 100644 tests/e2e/browser_omemo_restore.py diff --git a/Cargo.toml b/Cargo.toml index 7ea81ff..ba0d83d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ serde = { version = "1.0.229", features = ["derive"] } serde_json = { version = "1.0.154", features = ["preserve_order"] } sha1 = "0.11.0" sha2 = "0.11.1" -sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite", "chrono"] } +sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite", "postgres", "chrono"] } tokio = { version = "1", features = ["full"] } tokio-xmpp = { version = "6", features = ["insecure-tcp"] } tower-http = { version = "0.7", features = ["fs"] } diff --git a/README.md b/README.md index 3f0e769..2e2cb7a 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ A rewrite of the [Movim](https://movim.eu) XMPP web client backend in Rust, keeping Movim's frontend (CSS/JS/theme, in `frontend/`). The PHP daemon, widgets and -templates were replaced by a single Rust server (tokio, axum, tokio-xmpp, sqlx/SQLite, -minijinja). Scope: a Conversations-like chat client. +templates were replaced by a single Rust server (tokio, axum, tokio-xmpp, sqlx with SQLite +or PostgreSQL, minijinja). Scope: a Conversations-like chat client. ## Features 1:1 chat, group chats (MUC, bookmarks, invitations, moderation, configuration), @@ -35,8 +35,22 @@ XMPP server's archive expiry), dev only: `MOVIM_XMPP_PLAINTEXT=host:port`, `MOVIM_ALLOW_PRIVATE_URLS=1`, `MOVIM_DEBUG_XMPP=1`. +## Database +SQLite by default (`data/movim.sqlite`). For PostgreSQL, set +`MOVIM_DATABASE_URL=postgres://user:password@host/database`; the tables are +created at startup. To move an existing SQLite database, stop Movim, create an +empty PostgreSQL database and run: + + movim migrate-to-postgres postgres://user:password@host/database [--from sqlite:///path/movim.sqlite] + +Without `--from` it copies the SQLite database Movim would use (`MOVIM_DATABASE_URL` +or the one in `MOVIM_DATA_DIR`). Everything is copied in one transaction, ids +included, and the command refuses a database that already has data. The SQLite +file is left untouched. + ## Tests -`cargo test` runs the unit tests. `tests/e2e/` holds end-to-end scripts that +`cargo test` runs the unit tests (`MOVIM_TEST_POSTGRES_URL=postgres://…` also +runs them against PostgreSQL, in throwaway databases). `tests/e2e/` holds end-to-end scripts that drive the server (and the real frontend in headless Chromium) against a local Prosody; see `tests/e2e/README.md`. diff --git a/data/movim.sqlite b/data/movim.sqlite index 6f93d9100a4b4cbdc770091d8b35ba7207a97bda..782ec7be17c3680d36a4c536d4b2cdd353122f67 100644 GIT binary patch delta 11765 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 diff --git a/data/movim.sqlite-wal b/data/movim.sqlite-wal deleted file mode 100644 index 82936b9f40d5e9585c37095aa543035e5feb4695..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1203072 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 lGLkiU{Jl_43^X!vwDXT2 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 Oe1Vw6l6PlZ;Qt4h=tHgm diff --git a/frontend/widgets/Account/account.js b/frontend/widgets/Account/account.js index 7b8940e..8108453 100644 --- a/frontend/widgets/Account/account.js +++ b/frontend/widgets/Account/account.js @@ -76,10 +76,13 @@ var Account = { Object.keys(localStorage).filter(k => k.startsWith(prefix)).forEach(k => keys[k] = localStorage.getItem(k)); let deviceId = await store.getLocalRegistrationId(); + // The decrypted bodies too: our own messages are not encrypted for this device and, + // the sessions being reset on restore, the older ones cannot be decrypted again let payload = JSON.stringify({ jid: store.jid, deviceId: deviceId, - keys: keys + keys: keys, + messages: await ChatOmemoDB.getAllMessages() }); const salt = crypto.getRandomValues(new Uint8Array(16)); @@ -143,6 +146,15 @@ var Account = { Object.keys(localStorage).filter(k => k.startsWith(prefix)).forEach(k => localStorage.removeItem(k)); Object.entries(payload.keys).forEach(([k, v]) => localStorage.setItem(k, v)); + // The ratchets of the backup are behind the ones of our contacts: they cannot decrypt + // their new messages and break our next ones, new sessions are built on the next message + store.removeAllSessions(); + + // Backups made before the messages were included don't have them + if (Array.isArray(payload.messages)) { + await ChatOmemoDB.putMissingMessages(payload.messages); + } + localStorage.setObject(OMEMO_RESTORED_KEY, { replaced: replaced != payload.deviceId ? replaced ?? null : null }); // The page reload publishes the restored key (ChatOmemo.ownDevicesReceived) diff --git a/frontend/widgets/ChatOmemo/chatomemo.js b/frontend/widgets/ChatOmemo/chatomemo.js index 5624013..75e29ee 100644 --- a/frontend/widgets/ChatOmemo/chatomemo.js +++ b/frontend/widgets/ChatOmemo/chatomemo.js @@ -272,12 +272,29 @@ var ChatOmemo = { var store = new ChatOmemoStorage(); - let resolvedId = message.mine + // Our own messages are stored under the id this browser sent them with: in 1:1 + // the ones coming back from the archive (MAM) have another local id (m_…) + let resolvedId = (message.mine || message.jidfrom == USER_JID) && message.messageid ? message.messageid : message.id; + // A received message can be decrypted only once, but its local id changes when it comes + // back from the archive (MAM): its plaintext is also kept under the sender and the id they chose + let stableId = resolvedId == message.id && message.messageid + ? ['received', message.jidfrom, message.type == 'groupchat' ? message.resource : '', message.messageid].join('|') + : null; + + if (stableId) { + let stable = await ChatOmemoDB.getMessage(stableId); + if (typeof stable == 'string') return stable; + } + let maybeDecrypted = await ChatOmemoDB.getMessage(resolvedId); + if (typeof maybeDecrypted == 'string' && stableId) { + ChatOmemoDB.putMessage(stableId, maybeDecrypted); + } + if (maybeDecrypted !== undefined) { return maybeDecrypted; } @@ -369,6 +386,7 @@ var ChatOmemo = { let plaintext = MovimUtils.arrayBufferToString(decryptedBuffer); ChatOmemoDB.putMessage(resolvedId, plaintext); + if (stableId) ChatOmemoDB.putMessage(stableId, plaintext); return plaintext; }, enableContactState: function (jid, muc) { diff --git a/frontend/widgets/ChatOmemo/chatomemo_db.js b/frontend/widgets/ChatOmemo/chatomemo_db.js index cd41bbe..1745c97 100644 --- a/frontend/widgets/ChatOmemo/chatomemo_db.js +++ b/frontend/widgets/ChatOmemo/chatomemo_db.js @@ -65,6 +65,41 @@ var ChatOmemoDB = { return promise; }, + // All the decrypted bodies, for the key backup (Account.omemoBackup) + getAllMessages: async function() { + return new Promise(resolve => { + var tx = ChatOmemoDB.db.result.transaction(ChatOmemoDB.storeName, 'readonly'); + var request = tx.objectStore(ChatOmemoDB.storeName).getAll(); + request.onsuccess = function(event) { + resolve(event.target.result.filter(m => typeof m.body == 'string')); + } + request.onerror = function() { + resolve([]); + } + }); + }, + + // Restore them without overwriting what this browser already decrypted + putMissingMessages: async function(messages) { + return new Promise(resolve => { + var tx = ChatOmemoDB.db.result.transaction(ChatOmemoDB.storeName, 'readwrite'); + var os = tx.objectStore(ChatOmemoDB.storeName); + + messages.forEach(m => { + if (typeof m.id != 'string' || typeof m.body != 'string') return; + + os.get(m.id).onsuccess = function(event) { + if (!event.target.result || typeof event.target.result.body != 'string') { + os.put({"id" : m.id, "body" : m.body}); + } + } + }); + + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); + }, + getMessage: async function(id) { let promise = new Promise(resolve => { if (id in ChatOmemoDB.cachedMessages) { diff --git a/src/db.rs b/src/db.rs index ffe4a5a..7253996 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,14 +1,91 @@ -//! SQLite persistence: users, quick-login passwords, contacts (vCards/avatars), -//! messages, files, reactions and MAM state. +//! Persistence (SQLite or PostgreSQL): users, quick-login passwords, contacts +//! (vCards/avatars), messages, files, reactions and MAM state. +//! +//! Queries are written once, in SQL both databases understand, with SQLite's `?` +//! placeholders: `Db::sql` rewrites them for PostgreSQL. `on!` compiles the code +//! running a query once per database, rows keep their native types. use anyhow::Result; use serde::Serialize; +use sqlx::postgres::{PgPool, PgPoolOptions}; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; use sqlx::{AssertSqlSafe, FromRow, SqlitePool}; use std::str::FromStr; use std::time::Duration; -pub type Db = SqlitePool; +#[derive(Clone, Debug)] +pub enum Db { + Sqlite(SqlitePool), + Postgres(PgPool), +} + +/// Run `$body` with `$p` bound to the pool of the database (a trailing `;` +/// discards its value, the types of query results differ between databases) +macro_rules! on { + ($db:expr, $p:ident => $body:expr ;) => { + match $db { + Db::Sqlite($p) => { + $body; + } + Db::Postgres($p) => { + $body; + } + } + }; + ($db:expr, $p:ident => $body:expr) => { + match $db { + Db::Sqlite($p) => $body, + Db::Postgres($p) => $body, + } + }; +} + +/// SQLite's CURRENT_TIMESTAMP, dates are stored as text in this format +const POSTGRES_NOW: &str = "to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')"; + +/// `?` placeholders become `$1`, `$2`… and CURRENT_TIMESTAMP keeps SQLite's format +pub fn to_postgres(sql: &str) -> String { + let sql = sql.replace("CURRENT_TIMESTAMP", POSTGRES_NOW); + let mut out = String::with_capacity(sql.len() + 16); + let (mut n, mut quoted) = (0, false); + for c in sql.chars() { + match c { + '\'' => { + quoted = !quoted; + out.push(c); + } + '?' if !quoted => { + n += 1; + out.push_str(&format!("${n}")); + } + _ => out.push(c), + } + } + out +} + +impl Db { + pub fn sql(&self, sql: &str) -> AssertSqlSafe { + match self { + Db::Sqlite(_) => AssertSqlSafe(sql.to_string()), + Db::Postgres(_) => AssertSqlSafe(to_postgres(sql)), + } + } + + #[cfg(test)] + pub async fn close(&self) { + on!(self, p => p.close().await) + } +} + +pub fn is_postgres_url(url: &str) -> bool { + url.starts_with("postgres://") || url.starts_with("postgresql://") +} + +/// A date `days` ago, in the stored format +fn days_ago_sql(days: i64) -> String { + (chrono::Utc::now() - chrono::Duration::days(days)).format("%Y-%m-%d %H:%M:%S").to_string() +} const SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS users ( @@ -164,7 +241,180 @@ CREATE TABLE IF NOT EXISTS push_subscriptions ( ); "#; +/// Same tables as `SCHEMA`: real booleans, 64 bits integers, and the binary +/// collation of SQLite on the texts (the dates are compared as texts). +/// push_subscriptions gets a `rowid` column, the implicit one of SQLite. +const POSTGRES_SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS users ( + id TEXT COLLATE "C" PRIMARY KEY, + nickname TEXT, + language TEXT, + nightmode BOOLEAN NOT NULL DEFAULT FALSE, + chatmain BOOLEAN NOT NULL DEFAULT TRUE, + notificationchat BOOLEAN NOT NULL DEFAULT TRUE, + notificationcall BOOLEAN NOT NULL DEFAULT TRUE, + omemoenabled BOOLEAN NOT NULL DEFAULT TRUE, + accentcolor TEXT, + admin BOOLEAN NOT NULL DEFAULT FALSE, + chats TEXT NOT NULL DEFAULT '{}', + created_at TEXT COLLATE "C" NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT COLLATE "C" NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS encrypted_passwords ( + id TEXT COLLATE "C" NOT NULL, + user_id TEXT COLLATE "C" NOT NULL, + data TEXT NOT NULL, + updated_at TEXT COLLATE "C" NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, id) +); + +CREATE TABLE IF NOT EXISTS contacts ( + id TEXT COLLATE "C" PRIMARY KEY, + fn TEXT, + name TEXT, + nickname TEXT, + email TEXT, + url TEXT, + description TEXT, + avatarhash TEXT, + avatartype TEXT, + updated_at TEXT COLLATE "C" NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS avatars ( + hash TEXT COLLATE "C" PRIMARY KEY, + mime TEXT NOT NULL, + data BYTEA NOT NULL +); + +CREATE TABLE IF NOT EXISTS messages ( + mid BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id TEXT COLLATE "C" NOT NULL, + id TEXT COLLATE "C" NOT NULL, + messageid TEXT COLLATE "C", + originid TEXT COLLATE "C", + stanzaid TEXT COLLATE "C", + replaceid TEXT COLLATE "C", + jidto TEXT COLLATE "C" NOT NULL, + jidfrom TEXT COLLATE "C" NOT NULL, + resource TEXT COLLATE "C", + type TEXT COLLATE "C" NOT NULL DEFAULT 'chat', + subject TEXT, + thread TEXT COLLATE "C", + body TEXT, + html TEXT, + quoted BOOLEAN NOT NULL DEFAULT FALSE, + markable BOOLEAN NOT NULL DEFAULT FALSE, + seen BOOLEAN NOT NULL DEFAULT FALSE, + delivered TEXT COLLATE "C", + displayed TEXT COLLATE "C", + retracted BOOLEAN NOT NULL DEFAULT FALSE, + encrypted BOOLEAN NOT NULL DEFAULT FALSE, + omemoheader TEXT, + bundleid BIGINT, + parentmid BIGINT, + resolved BOOLEAN NOT NULL DEFAULT FALSE, + published TEXT COLLATE "C" NOT NULL, + created_at TEXT COLLATE "C" NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT COLLATE "C" NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, jidfrom, id) +); +CREATE INDEX IF NOT EXISTS messages_user_jidfrom ON messages (user_id, jidfrom); +CREATE INDEX IF NOT EXISTS messages_user_jidto ON messages (user_id, jidto); +CREATE INDEX IF NOT EXISTS messages_user_published ON messages (user_id, published); +CREATE INDEX IF NOT EXISTS messages_published ON messages (published); +CREATE INDEX IF NOT EXISTS messages_user_stanzaid ON messages (user_id, stanzaid); +CREATE INDEX IF NOT EXISTS messages_user_originid ON messages (user_id, originid); +CREATE INDEX IF NOT EXISTS messages_user_messageid ON messages (user_id, messageid); +CREATE INDEX IF NOT EXISTS messages_user_replaceid ON messages (user_id, replaceid); + +CREATE TABLE IF NOT EXISTS message_files ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + message_mid BIGINT NOT NULL, + name TEXT, + type TEXT, + size BIGINT, + url TEXT NOT NULL, + disposition TEXT, + thumbnail_url TEXT, + thumbnail_type TEXT, + thumbnail_width BIGINT, + thumbnail_height BIGINT +); +CREATE INDEX IF NOT EXISTS message_files_mid ON message_files (message_mid); + +CREATE TABLE IF NOT EXISTS reactions ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + message_mid BIGINT NOT NULL, + jidfrom TEXT COLLATE "C" NOT NULL, + emoji TEXT COLLATE "C" NOT NULL, + created_at TEXT COLLATE "C" NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (message_mid, jidfrom, emoji) +); + +CREATE TABLE IF NOT EXISTS mam_earliests ( + user_id TEXT COLLATE "C" NOT NULL, + jid TEXT COLLATE "C" NOT NULL, + earliest TEXT COLLATE "C", + PRIMARY KEY (user_id, jid) +); + +CREATE TABLE IF NOT EXISTS history_clears ( + user_id TEXT COLLATE "C" NOT NULL, + jid TEXT COLLATE "C" NOT NULL, + cleared_at TEXT COLLATE "C" NOT NULL, + PRIMARY KEY (user_id, jid) +); + +CREATE TABLE IF NOT EXISTS omemo_states ( + user_id TEXT COLLATE "C" NOT NULL, + jid TEXT COLLATE "C" NOT NULL, + enabled BOOLEAN NOT NULL, + PRIMARY KEY (user_id, jid) +); + +CREATE TABLE IF NOT EXISTS open_chats ( + user_id TEXT COLLATE "C" NOT NULL, + jid TEXT COLLATE "C" NOT NULL, + updated_at TEXT COLLATE "C" NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, jid) +); + +CREATE TABLE IF NOT EXISTS urls ( + hash TEXT COLLATE "C" PRIMARY KEY, + url TEXT NOT NULL, + data TEXT NOT NULL, + created_at TEXT COLLATE "C" NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS push_subscriptions ( + rowid BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE, + endpoint TEXT COLLATE "C" PRIMARY KEY, + user_id TEXT COLLATE "C" NOT NULL, + p256dh TEXT, + auth TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + activity_at TEXT COLLATE "C" NOT NULL DEFAULT CURRENT_TIMESTAMP +); +"#; + +/// Tables with an automatic id: its sequence must follow copied rows +pub const IDENTITY_COLUMNS: &[(&str, &str)] = + &[("messages", "mid"), ("message_files", "id"), ("reactions", "id"), ("push_subscriptions", "rowid")]; + pub async fn connect(url: &str) -> Result { + if is_postgres_url(url) { + let pool = PgPoolOptions::new().max_connections(16).connect(url).await?; + for statement in POSTGRES_SCHEMA.split(';') { + let statement = statement.trim(); + if !statement.is_empty() { + sqlx::query(AssertSqlSafe(to_postgres(statement))).execute(&pool).await?; + } + } + return Ok(Db::Postgres(pool)); + } + // WAL: readers never block the writer. synchronous=NORMAL is safe in WAL // (no corruption, a power loss can only drop the last commits) and avoids an // fsync per message. Writers queue on the busy timeout instead of failing. @@ -179,7 +429,7 @@ pub async fn connect(url: &str) -> Result { sqlx::query(statement).execute(&pool).await?; } } - Ok(pool) + Ok(Db::Sqlite(pool)) } // ---------------------------------------------------------------- Users @@ -203,21 +453,21 @@ impl User { } pub async fn user_get(db: &Db, id: &str) -> Result> { - Ok(sqlx::query_as::<_, User>( + let sql = db.sql( "SELECT id, nickname, language, nightmode, chatmain, notificationchat, notificationcall, omemoenabled, accentcolor, admin, chats FROM users WHERE id = ?", - ) - .bind(id) - .fetch_optional(db) - .await?) + ); + Ok(on!(db, p => sqlx::query_as::<_, User>(sql).bind(id).fetch_optional(p).await?)) } pub async fn user_get_or_create(db: &Db, id: &str) -> Result { - sqlx::query("INSERT OR IGNORE INTO users (id) VALUES (?)").bind(id).execute(db).await?; + let sql = db.sql("INSERT INTO users (id) VALUES (?) ON CONFLICT DO NOTHING"); + on!(db, p => sqlx::query(sql).bind(id).execute(p).await?;); Ok(user_get(db, id).await?.unwrap_or_default()) } pub async fn user_count(db: &Db) -> Result { - Ok(sqlx::query_scalar("SELECT COUNT(*) FROM users").fetch_one(db).await?) + let sql = db.sql("SELECT COUNT(*) FROM users"); + Ok(on!(db, p => sqlx::query_scalar(sql).fetch_one(p).await?)) } pub async fn user_set(db: &Db, id: &str, field: &str, value: &serde_json::Value) -> Result<()> { @@ -233,58 +483,52 @@ pub async fn user_set(db: &Db, id: &str, field: &str, value: &serde_json::Value) "chats", ]; anyhow::ensure!(ALLOWED.contains(&field), "field not allowed"); - let sql = format!("UPDATE users SET {field} = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); - let q = sqlx::query(AssertSqlSafe(sql)); - let q = match value { - serde_json::Value::Bool(b) => q.bind(*b), - serde_json::Value::Null => q.bind(None::), - serde_json::Value::String(s) => q.bind(s.clone()), - other => q.bind(other.to_string()), - }; - q.bind(id).execute(db).await?; + let sql = db.sql(&format!("UPDATE users SET {field} = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")); + on!(db, p => { + let q = sqlx::query(sql); + let q = match value { + serde_json::Value::Bool(b) => q.bind(*b), + serde_json::Value::Null => q.bind(None::), + serde_json::Value::String(s) => q.bind(s.clone()), + other => q.bind(other.to_string()), + }; + q.bind(id).execute(p).await?; + }); Ok(()) } // ---------------------------------------------------------------- Quick login pub async fn encrypted_password_set(db: &Db, user_id: &str, id: &str, data: &str) -> Result<()> { - sqlx::query("INSERT OR REPLACE INTO encrypted_passwords (id, user_id, data, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)") - .bind(id) - .bind(user_id) - .bind(data) - .execute(db) - .await?; + let sql = db.sql( + "INSERT INTO encrypted_passwords (id, user_id, data, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id, id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at", + ); + on!(db, p => sqlx::query(sql).bind(id).bind(user_id).bind(data).execute(p).await?;); Ok(()) } pub async fn encrypted_password_get(db: &Db, user_id: &str, id: &str) -> Result> { - let data = sqlx::query_scalar("SELECT data FROM encrypted_passwords WHERE user_id = ? AND id = ?") - .bind(user_id) - .bind(id) - .fetch_optional(db) - .await?; + let sql = db.sql("SELECT data FROM encrypted_passwords WHERE user_id = ? AND id = ?"); + let data: Option = on!(db, p => sqlx::query_scalar(sql).bind(user_id).bind(id).fetch_optional(p).await?); if data.is_some() { - sqlx::query("UPDATE encrypted_passwords SET updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND id = ?") - .bind(user_id) - .bind(id) - .execute(db) - .await?; + let sql = db.sql("UPDATE encrypted_passwords SET updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND id = ?"); + on!(db, p => sqlx::query(sql).bind(user_id).bind(id).execute(p).await?;); } Ok(data) } pub async fn encrypted_passwords_clear(db: &Db, user_id: &str) -> Result<()> { - sqlx::query("DELETE FROM encrypted_passwords WHERE user_id = ?").bind(user_id).execute(db).await?; + let sql = db.sql("DELETE FROM encrypted_passwords WHERE user_id = ?"); + on!(db, p => sqlx::query(sql).bind(user_id).execute(p).await?;); Ok(()) } pub async fn cleanup(db: &Db) -> Result<()> { - sqlx::query("DELETE FROM encrypted_passwords WHERE updated_at < datetime('now', '-7 days')") - .execute(db) - .await?; - sqlx::query("DELETE FROM push_subscriptions WHERE activity_at < datetime('now', '-30 days')") - .execute(db) - .await?; + let sql = db.sql("DELETE FROM encrypted_passwords WHERE updated_at < ?"); + on!(db, p => sqlx::query(sql).bind(days_ago_sql(7)).execute(p).await?;); + let sql = db.sql("DELETE FROM push_subscriptions WHERE activity_at < ?"); + on!(db, p => sqlx::query(sql).bind(days_ago_sql(30)).execute(p).await?;); Ok(()) } @@ -324,10 +568,8 @@ impl Contact { } pub async fn contact_get(db: &Db, id: &str) -> Result> { - Ok(sqlx::query_as::<_, Contact>("SELECT id, fn, name, nickname, email, url, description, avatarhash, avatartype FROM contacts WHERE id = ?") - .bind(id) - .fetch_optional(db) - .await?) + let sql = db.sql("SELECT id, fn, name, nickname, email, url, description, avatarhash, avatartype FROM contacts WHERE id = ?"); + Ok(on!(db, p => sqlx::query_as::<_, Contact>(sql).bind(id).fetch_optional(p).await?)) } pub async fn contact_get_or_new(db: &Db, id: &str) -> Contact { @@ -339,81 +581,79 @@ pub async fn contacts_get(db: &Db, ids: &[String]) -> Result> { return Ok(vec![]); } let placeholders = vec!["?"; ids.len()].join(","); - let sql = format!("SELECT id, fn, name, nickname, email, url, description, avatarhash, avatartype FROM contacts WHERE id IN ({placeholders})"); - let mut q = sqlx::query_as::<_, Contact>(AssertSqlSafe(sql)); - for id in ids { - q = q.bind(id); - } - Ok(q.fetch_all(db).await?) + let sql = db.sql(&format!( + "SELECT id, fn, name, nickname, email, url, description, avatarhash, avatartype FROM contacts WHERE id IN ({placeholders})" + )); + Ok(on!(db, p => { + let mut q = sqlx::query_as::<_, Contact>(sql); + for id in ids { + q = q.bind(id); + } + q.fetch_all(p).await? + })) } pub async fn contact_save(db: &Db, c: &Contact) -> Result<()> { - sqlx::query( + let sql = db.sql( "INSERT INTO contacts (id, fn, name, nickname, email, url, description, avatarhash, avatartype, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(id) DO UPDATE SET fn = excluded.fn, name = excluded.name, nickname = excluded.nickname, email = excluded.email, url = excluded.url, description = excluded.description, avatarhash = excluded.avatarhash, avatartype = excluded.avatartype, updated_at = CURRENT_TIMESTAMP", - ) - .bind(&c.id) - .bind(&c.fn_) - .bind(&c.name) - .bind(&c.nickname) - .bind(&c.email) - .bind(&c.url) - .bind(&c.description) - .bind(&c.avatarhash) - .bind(&c.avatartype) - .execute(db) - .await?; + ); + on!(db, p => { + sqlx::query(sql) + .bind(&c.id) + .bind(&c.fn_) + .bind(&c.name) + .bind(&c.nickname) + .bind(&c.email) + .bind(&c.url) + .bind(&c.description) + .bind(&c.avatarhash) + .bind(&c.avatartype) + .execute(p) + .await?; + }); Ok(()) } pub async fn contact_set_avatar(db: &Db, id: &str, hash: Option<&str>, kind: &str) -> Result<()> { - sqlx::query( + let sql = db.sql( "INSERT INTO contacts (id, avatarhash, avatartype) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET avatarhash = excluded.avatarhash, avatartype = excluded.avatartype, updated_at = CURRENT_TIMESTAMP", - ) - .bind(id) - .bind(hash) - .bind(kind) - .execute(db) - .await?; + ); + on!(db, p => sqlx::query(sql).bind(id).bind(hash).bind(kind).execute(p).await?;); Ok(()) } pub async fn contact_set_nickname(db: &Db, id: &str, nickname: Option<&str>) -> Result<()> { - sqlx::query( + let sql = db.sql( "INSERT INTO contacts (id, nickname) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET nickname = excluded.nickname, updated_at = CURRENT_TIMESTAMP", - ) - .bind(id) - .bind(nickname) - .execute(db) - .await?; + ); + on!(db, p => sqlx::query(sql).bind(id).bind(nickname).execute(p).await?;); Ok(()) } pub async fn avatar_exists(db: &Db, hash: &str) -> Result { - let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM avatars WHERE hash = ?").bind(hash).fetch_one(db).await?; + let sql = db.sql("SELECT COUNT(*) FROM avatars WHERE hash = ?"); + let n: i64 = on!(db, p => sqlx::query_scalar(sql).bind(hash).fetch_one(p).await?); Ok(n > 0) } pub async fn avatar_save(db: &Db, hash: &str, mime: &str, data: &[u8]) -> Result<()> { - sqlx::query("INSERT OR REPLACE INTO avatars (hash, mime, data) VALUES (?, ?, ?)") - .bind(hash) - .bind(mime) - .bind(data) - .execute(db) - .await?; + let sql = db.sql( + "INSERT INTO avatars (hash, mime, data) VALUES (?, ?, ?) + ON CONFLICT(hash) DO UPDATE SET mime = excluded.mime, data = excluded.data", + ); + on!(db, p => sqlx::query(sql).bind(hash).bind(mime).bind(data).execute(p).await?;); Ok(()) } pub async fn avatar_get(db: &Db, hash: &str) -> Result)>> { - Ok(sqlx::query_as::<_, (String, Vec)>("SELECT mime, data FROM avatars WHERE hash = ?") - .bind(hash) - .fetch_optional(db) - .await?) + let sql = db.sql("SELECT mime, data FROM avatars WHERE hash = ?"); + Ok(on!(db, p => sqlx::query_as::<_, (String, Vec)>(sql).bind(hash).fetch_optional(p).await?)) } // ---------------------------------------------------------------- Messages @@ -520,141 +760,134 @@ pub struct Reaction { /// Find the stored version of an incoming message, see `Message::findByStanza` pub async fn message_find_by_stanzaid(db: &Db, user: &str, jidfrom: &str, stanzaid: &str) -> Result> { - Ok(sqlx::query_as::<_, Message>(AssertSqlSafe(format!( + let sql = db.sql(&format!( "SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND jidfrom = ? AND stanzaid = ? LIMIT 1" - ))) - .bind(user) - .bind(jidfrom) - .bind(stanzaid) - .fetch_optional(db) - .await?) + )); + Ok(on!(db, p => sqlx::query_as::<_, Message>(sql).bind(user).bind(jidfrom).bind(stanzaid).fetch_optional(p).await?)) } pub async fn message_find_by_originid(db: &Db, user: &str, jidfrom: &str, originid: &str) -> Result> { - Ok(sqlx::query_as::<_, Message>(AssertSqlSafe(format!( + let sql = db.sql(&format!( "SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND jidfrom = ? AND (originid = ? OR id = ?) LIMIT 1" - ))) - .bind(user) - .bind(jidfrom) - .bind(originid) - .bind(originid) - .fetch_optional(db) - .await?) + )); + Ok(on!(db, p => { + sqlx::query_as::<_, Message>(sql).bind(user).bind(jidfrom).bind(originid).bind(originid).fetch_optional(p).await? + })) } pub async fn message_by_mid(db: &Db, user: &str, mid: i64) -> Result> { - Ok(sqlx::query_as::<_, Message>(AssertSqlSafe(format!("SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND mid = ?"))) - .bind(user) - .bind(mid) - .fetch_optional(db) - .await?) + let sql = db.sql(&format!("SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND mid = ?")); + Ok(on!(db, p => sqlx::query_as::<_, Message>(sql).bind(user).bind(mid).fetch_optional(p).await?)) } pub async fn message_by_id(db: &Db, user: &str, id: &str) -> Result> { - Ok(sqlx::query_as::<_, Message>(AssertSqlSafe(format!("SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND id = ? LIMIT 1"))) - .bind(user) - .bind(id) - .fetch_optional(db) - .await?) + let sql = db.sql(&format!("SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND id = ? LIMIT 1")); + Ok(on!(db, p => sqlx::query_as::<_, Message>(sql).bind(user).bind(id).fetch_optional(p).await?)) } /// Resolve a message referenced by another one (reply, reaction, retraction, receipt) /// from the conversation `jid`, by its message id, origin id or stanza id. pub async fn message_resolve_reference(db: &Db, user: &str, jid: &str, reference: &str) -> Result> { - Ok(sqlx::query_as::<_, Message>(AssertSqlSafe(format!( + let sql = db.sql(&format!( "SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?) AND (messageid = ? OR originid = ? OR stanzaid = ? OR id = ?) ORDER BY mid DESC LIMIT 1" - ))) - .bind(user) - .bind(jid) - .bind(jid) - .bind(reference) - .bind(reference) - .bind(reference) - .bind(reference) - .fetch_optional(db) - .await?) + )); + Ok(on!(db, p => { + sqlx::query_as::<_, Message>(sql) + .bind(user) + .bind(jid) + .bind(jid) + .bind(reference) + .bind(reference) + .bind(reference) + .bind(reference) + .fetch_optional(p) + .await? + })) } /// Insert or update a message, returns its mid pub async fn message_save(db: &Db, m: &Message) -> Result { if m.mid > 0 { - sqlx::query( + let sql = db.sql( "UPDATE messages SET id = ?, messageid = ?, originid = ?, stanzaid = ?, replaceid = ?, jidto = ?, jidfrom = ?, resource = ?, type = ?, subject = ?, thread = ?, body = ?, html = ?, quoted = ?, markable = ?, seen = ?, delivered = ?, displayed = ?, retracted = ?, encrypted = ?, omemoheader = ?, bundleid = ?, parentmid = ?, resolved = ?, published = ?, updated_at = CURRENT_TIMESTAMP WHERE mid = ?", - ) - .bind(&m.id) - .bind(&m.messageid) - .bind(&m.originid) - .bind(&m.stanzaid) - .bind(&m.replaceid) - .bind(&m.jidto) - .bind(&m.jidfrom) - .bind(&m.resource) - .bind(&m.type_) - .bind(&m.subject) - .bind(&m.thread) - .bind(&m.body) - .bind(&m.html) - .bind(m.quoted) - .bind(m.markable) - .bind(m.seen) - .bind(&m.delivered) - .bind(&m.displayed) - .bind(m.retracted) - .bind(m.encrypted) - .bind(&m.omemoheader) - .bind(m.bundleid) - .bind(m.parentmid) - .bind(m.resolved) - .bind(&m.published) - .bind(m.mid) - .execute(db) - .await?; + ); + on!(db, p => { + sqlx::query(sql) + .bind(&m.id) + .bind(&m.messageid) + .bind(&m.originid) + .bind(&m.stanzaid) + .bind(&m.replaceid) + .bind(&m.jidto) + .bind(&m.jidfrom) + .bind(&m.resource) + .bind(&m.type_) + .bind(&m.subject) + .bind(&m.thread) + .bind(&m.body) + .bind(&m.html) + .bind(m.quoted) + .bind(m.markable) + .bind(m.seen) + .bind(&m.delivered) + .bind(&m.displayed) + .bind(m.retracted) + .bind(m.encrypted) + .bind(&m.omemoheader) + .bind(m.bundleid) + .bind(m.parentmid) + .bind(m.resolved) + .bind(&m.published) + .bind(m.mid) + .execute(p) + .await?; + }); return Ok(m.mid); } - let res = sqlx::query( + let sql = db.sql( "INSERT INTO messages (user_id, id, messageid, originid, stanzaid, replaceid, jidto, jidfrom, resource, type, subject, thread, body, html, quoted, markable, seen, delivered, displayed, retracted, encrypted, omemoheader, bundleid, parentmid, resolved, published) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(user_id, jidfrom, id) DO UPDATE SET body = excluded.body, stanzaid = COALESCE(excluded.stanzaid, stanzaid), - seen = MAX(seen, excluded.seen), updated_at = CURRENT_TIMESTAMP + ON CONFLICT(user_id, jidfrom, id) DO UPDATE SET body = excluded.body, stanzaid = COALESCE(excluded.stanzaid, messages.stanzaid), + seen = (messages.seen OR excluded.seen), updated_at = CURRENT_TIMESTAMP RETURNING mid", - ) - .bind(&m.user_id) - .bind(&m.id) - .bind(&m.messageid) - .bind(&m.originid) - .bind(&m.stanzaid) - .bind(&m.replaceid) - .bind(&m.jidto) - .bind(&m.jidfrom) - .bind(&m.resource) - .bind(&m.type_) - .bind(&m.subject) - .bind(&m.thread) - .bind(&m.body) - .bind(&m.html) - .bind(m.quoted) - .bind(m.markable) - .bind(m.seen) - .bind(&m.delivered) - .bind(&m.displayed) - .bind(m.retracted) - .bind(m.encrypted) - .bind(&m.omemoheader) - .bind(m.bundleid) - .bind(m.parentmid) - .bind(m.resolved) - .bind(&m.published) - .fetch_one(db) - .await?; - - use sqlx::Row; - Ok(res.get::(0)) + ); + Ok(on!(db, p => { + sqlx::query_scalar(sql) + .bind(&m.user_id) + .bind(&m.id) + .bind(&m.messageid) + .bind(&m.originid) + .bind(&m.stanzaid) + .bind(&m.replaceid) + .bind(&m.jidto) + .bind(&m.jidfrom) + .bind(&m.resource) + .bind(&m.type_) + .bind(&m.subject) + .bind(&m.thread) + .bind(&m.body) + .bind(&m.html) + .bind(m.quoted) + .bind(m.markable) + .bind(m.seen) + .bind(&m.delivered) + .bind(&m.displayed) + .bind(m.retracted) + .bind(m.encrypted) + .bind(&m.omemoheader) + .bind(m.bundleid) + .bind(m.parentmid) + .bind(m.resolved) + .bind(&m.published) + .fetch_one(p) + .await? + })) } /// Messages of a conversation, newest first @@ -684,118 +917,121 @@ pub async fn messages_for( sql.push_str(if after.is_some() { " ORDER BY published ASC, mid ASC" } else { " ORDER BY published DESC, mid DESC" }); sql.push_str(" LIMIT ?"); - let mut q = sqlx::query_as::<_, Message>(AssertSqlSafe(sql)).bind(user).bind(jid).bind(jid); - if let Some(b) = before { - q = q.bind(b); - } - if let Some(a) = after { - q = q.bind(a); - } - Ok(q.bind(limit).fetch_all(db).await?) + let sql = db.sql(&sql); + Ok(on!(db, p => { + let mut q = sqlx::query_as::<_, Message>(sql).bind(user).bind(jid).bind(jid); + if let Some(b) = before { + q = q.bind(b); + } + if let Some(a) = after { + q = q.bind(a); + } + q.bind(limit).fetch_all(p).await? + })) } pub async fn messages_count(db: &Db, user: &str, jid: &str) -> Result { - Ok(sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)") - .bind(user) - .bind(jid) - .bind(jid) - .fetch_one(db) - .await?) + let sql = db.sql("SELECT COUNT(*) FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)"); + Ok(on!(db, p => sqlx::query_scalar(sql).bind(user).bind(jid).bind(jid).fetch_one(p).await?)) } /// Clear the local history of a conversation. The archive is marked as fully /// known and the clear date is kept, so the cleared messages are not fetched /// again from MAM or MUC history (`archive_key`: jid, or `muc:` + room). pub async fn messages_clear(db: &Db, user: &str, jid: &str, archive_key: &str) -> Result<()> { - let mut tx = db.begin().await?; - for sql in [ - "DELETE FROM reactions WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?))", - "DELETE FROM message_files WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?))", - "DELETE FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)", - ] { - sqlx::query(sql).bind(user).bind(jid).bind(jid).execute(&mut *tx).await?; - } - sqlx::query("INSERT OR REPLACE INTO mam_earliests (user_id, jid, earliest) VALUES (?, ?, NULL)") + on!(db, p => { + let mut tx = p.begin().await?; + for sql in [ + "DELETE FROM reactions WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?))", + "DELETE FROM message_files WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?))", + "DELETE FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)", + ] { + sqlx::query(db.sql(sql)).bind(user).bind(jid).bind(jid).execute(&mut *tx).await?; + } + sqlx::query(db.sql( + "INSERT INTO mam_earliests (user_id, jid, earliest) VALUES (?, ?, NULL) + ON CONFLICT(user_id, jid) DO UPDATE SET earliest = excluded.earliest", + )) .bind(user) .bind(archive_key) .execute(&mut *tx) .await?; - sqlx::query("INSERT OR REPLACE INTO history_clears (user_id, jid, cleared_at) VALUES (?, ?, ?)") + sqlx::query(db.sql( + "INSERT INTO history_clears (user_id, jid, cleared_at) VALUES (?, ?, ?) + ON CONFLICT(user_id, jid) DO UPDATE SET cleared_at = excluded.cleared_at", + )) .bind(user) .bind(archive_key) .bind(crate::utils::now_sql()) .execute(&mut *tx) .await?; - tx.commit().await?; + tx.commit().await?; + }); Ok(()) } /// Undo `messages_clear`: the cleared messages can be fetched again from MAM pub async fn history_restore(db: &Db, user: &str, archive_key: &str) -> Result<()> { - let mut tx = db.begin().await?; - for sql in [ - "DELETE FROM history_clears WHERE user_id = ? AND jid = ?", - "DELETE FROM mam_earliests WHERE user_id = ? AND jid = ?", - ] { - sqlx::query(sql).bind(user).bind(archive_key).execute(&mut *tx).await?; - } - tx.commit().await?; + on!(db, p => { + let mut tx = p.begin().await?; + for sql in [ + "DELETE FROM history_clears WHERE user_id = ? AND jid = ?", + "DELETE FROM mam_earliests WHERE user_id = ? AND jid = ?", + ] { + sqlx::query(db.sql(sql)).bind(user).bind(archive_key).execute(&mut *tx).await?; + } + tx.commit().await?; + }); Ok(()) } /// Delete the messages published more than `days` ago, with their reactions and /// files. Returns the number of deleted messages. pub async fn messages_expire(db: &Db, days: u32) -> Result { - let cutoff = (chrono::Utc::now() - chrono::Duration::days(days.into())).format("%Y-%m-%d %H:%M:%S").to_string(); - let mut tx = db.begin().await?; - for sql in [ - "DELETE FROM reactions WHERE message_mid IN (SELECT mid FROM messages WHERE published < ?)", - "DELETE FROM message_files WHERE message_mid IN (SELECT mid FROM messages WHERE published < ?)", - ] { - sqlx::query(sql).bind(&cutoff).execute(&mut *tx).await?; - } - let deleted = sqlx::query("DELETE FROM messages WHERE published < ?").bind(&cutoff).execute(&mut *tx).await?.rows_affected(); - tx.commit().await?; - Ok(deleted) + let cutoff = days_ago_sql(days.into()); + Ok(on!(db, p => { + let mut tx = p.begin().await?; + for sql in [ + "DELETE FROM reactions WHERE message_mid IN (SELECT mid FROM messages WHERE published < ?)", + "DELETE FROM message_files WHERE message_mid IN (SELECT mid FROM messages WHERE published < ?)", + ] { + sqlx::query(db.sql(sql)).bind(&cutoff).execute(&mut *tx).await?; + } + let deleted = sqlx::query(db.sql("DELETE FROM messages WHERE published < ?")) + .bind(&cutoff) + .execute(&mut *tx) + .await? + .rows_affected(); + tx.commit().await?; + deleted + })) } /// When the history of a conversation was last cleared (`archive_key` as above) pub async fn history_cleared_at(db: &Db, user: &str, archive_key: &str) -> Result> { - Ok(sqlx::query_scalar("SELECT cleared_at FROM history_clears WHERE user_id = ? AND jid = ?") - .bind(user) - .bind(archive_key) - .fetch_optional(db) - .await?) + let sql = db.sql("SELECT cleared_at FROM history_clears WHERE user_id = ? AND jid = ?"); + Ok(on!(db, p => sqlx::query_scalar(sql).bind(user).bind(archive_key).fetch_optional(p).await?)) } /// Latest clear of a 1:1 conversation pub async fn latest_chat_clear(db: &Db, user: &str) -> Result> { - Ok(sqlx::query_scalar("SELECT MAX(cleared_at) FROM history_clears WHERE user_id = ? AND jid NOT LIKE 'muc:%'") - .bind(user) - .fetch_one(db) - .await?) + let sql = db.sql("SELECT MAX(cleared_at) FROM history_clears WHERE user_id = ? AND jid NOT LIKE 'muc:%'"); + Ok(on!(db, p => sqlx::query_scalar(sql).bind(user).fetch_one(p).await?)) } pub async fn unreads_count(db: &Db, user: &str, jid: &str, muc: bool) -> Result { let types = if muc { MESSAGE_TYPE_MUC } else { MESSAGE_TYPE }; let types_sql = types.iter().map(|t| format!("'{t}'")).collect::>().join(","); - Ok(sqlx::query_scalar(AssertSqlSafe(format!( - "SELECT COUNT(*) FROM messages WHERE user_id = ? AND jidfrom = ? AND seen = 0 AND type IN ({types_sql})" - ))) - .bind(user) - .bind(jid) - .fetch_one(db) - .await?) + let sql = db.sql(&format!( + "SELECT COUNT(*) FROM messages WHERE user_id = ? AND jidfrom = ? AND seen = FALSE AND type IN ({types_sql})" + )); + Ok(on!(db, p => sqlx::query_scalar(sql).bind(user).bind(jid).fetch_one(p).await?)) } /// Number of conversations (1:1 and rooms in `rooms`) with unread messages pub async fn unreads_total(db: &Db, user: &str, rooms: &[String]) -> Result { - let rows: Vec<(String, String)> = sqlx::query_as( - "SELECT DISTINCT jidfrom, type FROM messages WHERE user_id = ? AND seen = 0 AND jidfrom != user_id", - ) - .bind(user) - .fetch_all(db) - .await?; + let sql = db.sql("SELECT DISTINCT jidfrom, type FROM messages WHERE user_id = ? AND seen = FALSE AND jidfrom != user_id"); + let rows: Vec<(String, String)> = on!(db, p => sqlx::query_as(sql).bind(user).fetch_all(p).await?); Ok(rows .iter() .filter(|(jid, t)| if t == "groupchat" { rooms.contains(jid) } else { MESSAGE_TYPE.contains(&t.as_str()) }) @@ -803,35 +1039,28 @@ pub async fn unreads_total(db: &Db, user: &str, rooms: &[String]) -> Result } pub async fn messages_mark_seen(db: &Db, user: &str, jid: &str) -> Result { - Ok(sqlx::query("UPDATE messages SET seen = 1 WHERE user_id = ? AND jidfrom = ? AND seen = 0") - .bind(user) - .bind(jid) - .execute(db) - .await? - .rows_affected()) + let sql = db.sql("UPDATE messages SET seen = TRUE WHERE user_id = ? AND jidfrom = ? AND seen = FALSE"); + Ok(on!(db, p => sqlx::query(sql).bind(user).bind(jid).execute(p).await?.rows_affected())) } pub async fn message_set_field(db: &Db, mid: i64, field: &str, value: Option<&str>) -> Result<()> { anyhow::ensure!(matches!(field, "delivered" | "displayed" | "body"), "field not allowed"); - sqlx::query(AssertSqlSafe(format!("UPDATE messages SET {field} = ?, updated_at = CURRENT_TIMESTAMP WHERE mid = ?"))) - .bind(value) - .bind(mid) - .execute(db) - .await?; + let sql = db.sql(&format!("UPDATE messages SET {field} = ?, updated_at = CURRENT_TIMESTAMP WHERE mid = ?")); + on!(db, p => sqlx::query(sql).bind(value).bind(mid).execute(p).await?;); Ok(()) } pub async fn message_retract(db: &Db, mid: i64) -> Result<()> { - sqlx::query("UPDATE messages SET retracted = 1, body = NULL, html = NULL, updated_at = CURRENT_TIMESTAMP WHERE mid = ?") - .bind(mid) - .execute(db) - .await?; - sqlx::query("DELETE FROM message_files WHERE message_mid = ?").bind(mid).execute(db).await?; + let sql = db.sql("UPDATE messages SET retracted = TRUE, body = NULL, html = NULL, updated_at = CURRENT_TIMESTAMP WHERE mid = ?"); + on!(db, p => sqlx::query(sql).bind(mid).execute(p).await?;); + let sql = db.sql("DELETE FROM message_files WHERE message_mid = ?"); + on!(db, p => sqlx::query(sql).bind(mid).execute(p).await?;); Ok(()) } pub async fn message_set_resolved(db: &Db, mid: i64) -> Result<()> { - sqlx::query("UPDATE messages SET resolved = 1 WHERE mid = ?").bind(mid).execute(db).await?; + let sql = db.sql("UPDATE messages SET resolved = TRUE WHERE mid = ?"); + on!(db, p => sqlx::query(sql).bind(mid).execute(p).await?;); Ok(()) } @@ -842,64 +1071,69 @@ pub async fn last_message(db: &Db, user: &str, jid: &str, muc: bool) -> Result Result> { - Ok(sqlx::query_scalar( + let sql = db.sql( "SELECT CASE WHEN jidfrom = user_id THEN jidto ELSE jidfrom END AS jid FROM messages WHERE user_id = ? AND type = 'chat' GROUP BY jid ORDER BY MAX(published) DESC LIMIT ?", - ) - .bind(user) - .bind(limit) - .fetch_all(db) - .await?) + ); + Ok(on!(db, p => sqlx::query_scalar(sql).bind(user).bind(limit).fetch_all(p).await?)) } // Resolved URLs (link previews) pub async fn url_get(db: &Db, hash: &str) -> Result> { - Ok(sqlx::query_scalar("SELECT data FROM urls WHERE hash = ?").bind(hash).fetch_optional(db).await?) + let sql = db.sql("SELECT data FROM urls WHERE hash = ?"); + Ok(on!(db, p => sqlx::query_scalar(sql).bind(hash).fetch_optional(p).await?)) } pub async fn url_save(db: &Db, hash: &str, url: &str, data: &str) -> Result<()> { - sqlx::query("INSERT OR REPLACE INTO urls (hash, url, data) VALUES (?, ?, ?)").bind(hash).bind(url).bind(data).execute(db).await?; + let sql = db.sql( + "INSERT INTO urls (hash, url, data) VALUES (?, ?, ?) + ON CONFLICT(hash) DO UPDATE SET url = excluded.url, data = excluded.data, created_at = excluded.created_at", + ); + on!(db, p => sqlx::query(sql).bind(hash).bind(url).bind(data).execute(p).await?;); Ok(()) } /// Forget the previews older than a month, the pages may have changed pub async fn urls_cleanup(db: &Db) -> Result<()> { - sqlx::query("DELETE FROM urls WHERE created_at < datetime('now', '-30 days')").execute(db).await?; + let sql = db.sql("DELETE FROM urls WHERE created_at < ?"); + on!(db, p => sqlx::query(sql).bind(days_ago_sql(30)).execute(p).await?;); Ok(()) } // Files pub async fn message_file_save(db: &Db, f: &MessageFile) -> Result<()> { - sqlx::query("DELETE FROM message_files WHERE message_mid = ?").bind(f.message_mid).execute(db).await?; - sqlx::query( + let sql = db.sql("DELETE FROM message_files WHERE message_mid = ?"); + on!(db, p => sqlx::query(sql).bind(f.message_mid).execute(p).await?;); + let sql = db.sql( "INSERT INTO message_files (message_mid, name, type, size, url, disposition, thumbnail_url, thumbnail_type, thumbnail_width, thumbnail_height) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(f.message_mid) - .bind(&f.name) - .bind(&f.type_) - .bind(f.size) - .bind(&f.url) - .bind(&f.disposition) - .bind(&f.thumbnail_url) - .bind(&f.thumbnail_type) - .bind(f.thumbnail_width) - .bind(f.thumbnail_height) - .execute(db) - .await?; + ); + on!(db, p => { + sqlx::query(sql) + .bind(f.message_mid) + .bind(&f.name) + .bind(&f.type_) + .bind(f.size) + .bind(&f.url) + .bind(&f.disposition) + .bind(&f.thumbnail_url) + .bind(&f.thumbnail_type) + .bind(f.thumbnail_width) + .bind(f.thumbnail_height) + .execute(p) + .await?; + }); Ok(()) } pub async fn message_file(db: &Db, mid: i64) -> Result> { - Ok(sqlx::query_as::<_, MessageFile>( + let sql = db.sql( "SELECT id, message_mid, name, type, size, url, disposition, thumbnail_url, thumbnail_type, thumbnail_width, thumbnail_height FROM message_files WHERE message_mid = ? LIMIT 1", - ) - .bind(mid) - .fetch_optional(db) - .await?) + ); + Ok(on!(db, p => sqlx::query_as::<_, MessageFile>(sql).bind(mid).fetch_optional(p).await?)) } // Reactions @@ -909,117 +1143,101 @@ pub async fn reactions_for(db: &Db, mids: &[i64]) -> Result> { return Ok(vec![]); } let placeholders = vec!["?"; mids.len()].join(","); - let sql = format!( + let sql = db.sql(&format!( "SELECT message_mid, jidfrom, emoji, created_at FROM reactions WHERE message_mid IN ({placeholders}) ORDER BY created_at, id" - ); - let mut q = sqlx::query_as::<_, Reaction>(AssertSqlSafe(sql)); - for mid in mids { - q = q.bind(mid); - } - Ok(q.fetch_all(db).await?) + )); + Ok(on!(db, p => { + let mut q = sqlx::query_as::<_, Reaction>(sql); + for mid in mids { + q = q.bind(mid); + } + q.fetch_all(p).await? + })) } pub async fn reactions_replace(db: &Db, mid: i64, jidfrom: &str, emojis: &[String]) -> Result<()> { - let mut tx = db.begin().await?; - sqlx::query("DELETE FROM reactions WHERE message_mid = ? AND jidfrom = ?").bind(mid).bind(jidfrom).execute(&mut *tx).await?; - for emoji in emojis { - sqlx::query("INSERT OR IGNORE INTO reactions (message_mid, jidfrom, emoji) VALUES (?, ?, ?)") + on!(db, p => { + let mut tx = p.begin().await?; + sqlx::query(db.sql("DELETE FROM reactions WHERE message_mid = ? AND jidfrom = ?")) .bind(mid) .bind(jidfrom) - .bind(emoji) .execute(&mut *tx) .await?; - } - tx.commit().await?; + for emoji in emojis { + sqlx::query(db.sql("INSERT INTO reactions (message_mid, jidfrom, emoji) VALUES (?, ?, ?) ON CONFLICT DO NOTHING")) + .bind(mid) + .bind(jidfrom) + .bind(emoji) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + }); Ok(()) } // MAM pub async fn mam_earliest_exists(db: &Db, user: &str, jid: &str) -> Result { - let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM mam_earliests WHERE user_id = ? AND jid = ?") - .bind(user) - .bind(jid) - .fetch_one(db) - .await?; + let sql = db.sql("SELECT COUNT(*) FROM mam_earliests WHERE user_id = ? AND jid = ?"); + let n: i64 = on!(db, p => sqlx::query_scalar(sql).bind(user).bind(jid).fetch_one(p).await?); Ok(n > 0) } pub async fn mam_earliest_set(db: &Db, user: &str, jid: &str, earliest: Option<&str>) -> Result<()> { - sqlx::query("INSERT OR REPLACE INTO mam_earliests (user_id, jid, earliest) VALUES (?, ?, ?)") - .bind(user) - .bind(jid) - .bind(earliest) - .execute(db) - .await?; + let sql = db.sql( + "INSERT INTO mam_earliests (user_id, jid, earliest) VALUES (?, ?, ?) + ON CONFLICT(user_id, jid) DO UPDATE SET earliest = excluded.earliest", + ); + on!(db, p => sqlx::query(sql).bind(user).bind(jid).bind(earliest).execute(p).await?;); Ok(()) } pub async fn earliest_message_date(db: &Db, user: &str, jid: &str) -> Result> { - Ok(sqlx::query_scalar("SELECT MIN(published) FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)") - .bind(user) - .bind(jid) - .bind(jid) - .fetch_one(db) - .await?) + let sql = db.sql("SELECT MIN(published) FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)"); + Ok(on!(db, p => sqlx::query_scalar(sql).bind(user).bind(jid).bind(jid).fetch_one(p).await?)) } pub async fn latest_message_date(db: &Db, user: &str) -> Result> { - Ok(sqlx::query_scalar("SELECT MAX(published) FROM messages WHERE user_id = ? AND type = 'chat'") - .bind(user) - .fetch_one(db) - .await?) + let sql = db.sql("SELECT MAX(published) FROM messages WHERE user_id = ? AND type = 'chat'"); + Ok(on!(db, p => sqlx::query_scalar(sql).bind(user).fetch_one(p).await?)) } pub async fn latest_muc_message_date(db: &Db, user: &str, room: &str) -> Result> { - Ok(sqlx::query_scalar("SELECT MAX(published) FROM messages WHERE user_id = ? AND jidfrom = ? AND type = 'groupchat'") - .bind(user) - .bind(room) - .fetch_one(db) - .await?) + let sql = db.sql("SELECT MAX(published) FROM messages WHERE user_id = ? AND jidfrom = ? AND type = 'groupchat'"); + Ok(on!(db, p => sqlx::query_scalar(sql).bind(user).bind(room).fetch_one(p).await?)) } // OMEMO per-conversation state pub async fn omemo_state(db: &Db, user: &str, jid: &str) -> Result> { - Ok(sqlx::query_scalar("SELECT enabled FROM omemo_states WHERE user_id = ? AND jid = ?") - .bind(user) - .bind(jid) - .fetch_optional(db) - .await?) + let sql = db.sql("SELECT enabled FROM omemo_states WHERE user_id = ? AND jid = ?"); + Ok(on!(db, p => sqlx::query_scalar(sql).bind(user).bind(jid).fetch_optional(p).await?)) } pub async fn omemo_state_set(db: &Db, user: &str, jid: &str, enabled: bool) -> Result<()> { - sqlx::query("INSERT OR REPLACE INTO omemo_states (user_id, jid, enabled) VALUES (?, ?, ?)") - .bind(user) - .bind(jid) - .bind(enabled) - .execute(db) - .await?; + let sql = db.sql( + "INSERT INTO omemo_states (user_id, jid, enabled) VALUES (?, ?, ?) + ON CONFLICT(user_id, jid) DO UPDATE SET enabled = excluded.enabled", + ); + on!(db, p => sqlx::query(sql).bind(user).bind(jid).bind(enabled).execute(p).await?;); Ok(()) } // Push subscriptions pub async fn push_subscription_save(db: &Db, user: &str, endpoint: &str, p256dh: &str, auth: &str) -> Result<()> { - sqlx::query( + let sql = db.sql( "INSERT INTO push_subscriptions (endpoint, user_id, p256dh, auth, activity_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(endpoint) DO UPDATE SET user_id = excluded.user_id, p256dh = excluded.p256dh, auth = excluded.auth, activity_at = CURRENT_TIMESTAMP", - ) - .bind(endpoint) - .bind(user) - .bind(p256dh) - .bind(auth) - .execute(db) - .await?; + ); + on!(db, p => sqlx::query(sql).bind(endpoint).bind(user).bind(p256dh).bind(auth).execute(p).await?;); Ok(()) } pub async fn push_subscription_touch(db: &Db, endpoint: &str) -> Result<()> { - sqlx::query("UPDATE push_subscriptions SET activity_at = CURRENT_TIMESTAMP WHERE endpoint = ?") - .bind(endpoint) - .execute(db) - .await?; + let sql = db.sql("UPDATE push_subscriptions SET activity_at = CURRENT_TIMESTAMP WHERE endpoint = ?"); + on!(db, p => sqlx::query(sql).bind(endpoint).execute(p).await?;); Ok(()) } @@ -1027,51 +1245,42 @@ pub async fn push_subscription_touch(db: &Db, endpoint: &str) -> Result<()> { pub async fn open_chat_touch(db: &Db, user: &str, jid: &str, at: Option<&str>) -> Result<()> { let at = at.map(String::from).unwrap_or_else(crate::utils::now_sql_micro); - sqlx::query( + let sql = db.sql( "INSERT INTO open_chats (user_id, jid, updated_at) VALUES (?, ?, ?) - ON CONFLICT(user_id, jid) DO UPDATE SET updated_at = MAX(updated_at, excluded.updated_at)", - ) - .bind(user) - .bind(jid) - .bind(at) - .execute(db) - .await?; + ON CONFLICT(user_id, jid) DO UPDATE SET updated_at = CASE WHEN excluded.updated_at > open_chats.updated_at + THEN excluded.updated_at ELSE open_chats.updated_at END", + ); + on!(db, p => sqlx::query(sql).bind(user).bind(jid).bind(&at).execute(p).await?;); Ok(()) } pub async fn open_chat_exists(db: &Db, user: &str, jid: &str) -> Result { - let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM open_chats WHERE user_id = ? AND jid = ?") - .bind(user) - .bind(jid) - .fetch_one(db) - .await?; + let sql = db.sql("SELECT COUNT(*) FROM open_chats WHERE user_id = ? AND jid = ?"); + let n: i64 = on!(db, p => sqlx::query_scalar(sql).bind(user).bind(jid).fetch_one(p).await?); Ok(n > 0) } pub async fn open_chat_close(db: &Db, user: &str, jid: &str) -> Result<()> { - sqlx::query("DELETE FROM open_chats WHERE user_id = ? AND jid = ?").bind(user).bind(jid).execute(db).await?; + let sql = db.sql("DELETE FROM open_chats WHERE user_id = ? AND jid = ?"); + on!(db, p => sqlx::query(sql).bind(user).bind(jid).execute(p).await?;); Ok(()) } /// Open chats, oldest first. Conversations with unread messages are opened automatically. pub async fn open_chats(db: &Db, user: &str) -> Result> { let types_sql = MESSAGE_TYPE.iter().map(|t| format!("'{t}'")).collect::>().join(","); - let unread: Vec<(String, String)> = sqlx::query_as(AssertSqlSafe(format!( - "SELECT jidfrom, MAX(published) FROM messages WHERE user_id = ? AND seen = 0 AND jidfrom != user_id + let sql = db.sql(&format!( + "SELECT jidfrom, MAX(published) FROM messages WHERE user_id = ? AND seen = FALSE AND jidfrom != user_id AND type IN ({types_sql}) GROUP BY jidfrom" - ))) - .bind(user) - .fetch_all(db) - .await?; + )); + let unread: Vec<(String, String)> = on!(db, p => sqlx::query_as(sql).bind(user).fetch_all(p).await?); for (jid, published) in unread { if !open_chat_exists(db, user, &jid).await? { open_chat_touch(db, user, &jid, Some(&published)).await?; } } - Ok(sqlx::query_scalar("SELECT jid FROM open_chats WHERE user_id = ? ORDER BY updated_at ASC") - .bind(user) - .fetch_all(db) - .await?) + let sql = db.sql("SELECT jid FROM open_chats WHERE user_id = ? ORDER BY updated_at ASC"); + Ok(on!(db, p => sqlx::query_scalar(sql).bind(user).fetch_all(p).await?)) } /// Plain text search in a discussion @@ -1095,15 +1304,11 @@ pub async fn messages_search( let types_sql = conversation_types(muc); let words: Vec = crate::utils::search_normalize(keywords).split_whitespace().take(8).map(String::from).collect(); - let rows: Vec<(i64, String)> = sqlx::query_as(AssertSqlSafe(format!( + let sql = db.sql(&format!( "SELECT mid, body FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?) AND type IN ({types_sql}) - AND encrypted = 0 AND retracted = 0 AND body IS NOT NULL AND {NOT_REPLACED} ORDER BY published DESC" - ))) - .bind(user) - .bind(jid) - .bind(jid) - .fetch_all(db) - .await?; + AND encrypted = FALSE AND retracted = FALSE AND body IS NOT NULL AND {NOT_REPLACED} ORDER BY published DESC" + )); + let rows: Vec<(i64, String)> = on!(db, p => sqlx::query_as(sql).bind(user).bind(jid).bind(jid).fetch_all(p).await?); let mut mids: Vec = rows .into_iter() .filter(|(_, body)| { @@ -1119,17 +1324,17 @@ pub async fn messages_search( } let placeholders = vec!["?"; mids.len()].join(","); - let mut q = sqlx::query_as::<_, Message>(AssertSqlSafe(format!( + let sql = db.sql(&format!( "SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?) AND type IN ({types_sql}) - AND retracted = 0 AND mid IN ({placeholders}) ORDER BY published DESC LIMIT ?" - ))) - .bind(user) - .bind(jid) - .bind(jid); - for mid in &mids { - q = q.bind(mid); - } - Ok(q.bind(limit).fetch_all(db).await?) + AND retracted = FALSE AND mid IN ({placeholders}) ORDER BY published DESC LIMIT ?" + )); + Ok(on!(db, p => { + let mut q = sqlx::query_as::<_, Message>(sql).bind(user).bind(jid).bind(jid); + for mid in &mids { + q = q.bind(mid); + } + q.bind(limit).fetch_all(p).await? + })) } /// Skips the messages that were corrected by their author (XEP-0308): only the last version is searched @@ -1137,21 +1342,17 @@ pub async fn messages_search( const NOT_REPLACED: &str = "NOT EXISTS (SELECT 1 FROM messages r WHERE r.user_id = messages.user_id AND r.replaceid IS NOT NULL AND (r.replaceid IN (messages.id, messages.messageid, messages.originid) OR r.replaceid = messages.replaceid AND r.mid > messages.mid) - AND r.jidfrom = messages.jidfrom AND (r.type = 'chat' OR r.resource IS messages.resource))"; + AND r.jidfrom = messages.jidfrom AND (r.type = 'chat' OR r.resource IS NOT DISTINCT FROM messages.resource))"; /// OMEMO messages of a conversation, newest first: (mid, id, messageid, published). /// The browser looks their decrypted bodies up with id or messageid. pub async fn encrypted_index(db: &Db, user: &str, jid: &str, muc: bool) -> Result, String)>> { let types_sql = conversation_types(muc); - Ok(sqlx::query_as(AssertSqlSafe(format!( + let sql = db.sql(&format!( "SELECT mid, id, messageid, published FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?) - AND type IN ({types_sql}) AND encrypted = 1 AND retracted = 0 AND {NOT_REPLACED} ORDER BY published DESC LIMIT 5000" - ))) - .bind(user) - .bind(jid) - .bind(jid) - .fetch_all(db) - .await?) + AND type IN ({types_sql}) AND encrypted = TRUE AND retracted = FALSE AND {NOT_REPLACED} ORDER BY published DESC LIMIT 5000" + )); + Ok(on!(db, p => sqlx::query_as(sql).bind(user).bind(jid).bind(jid).fetch_all(p).await?)) } #[derive(Debug, Clone, FromRow, Serialize)] @@ -1169,51 +1370,47 @@ pub struct ConversationFile { /// Files shared in a conversation (clear text ones), newest first pub async fn conversation_files(db: &Db, user: &str, jid: &str, muc: bool, limit: i64) -> Result> { let types_sql = conversation_types(muc); - Ok(sqlx::query_as(AssertSqlSafe(format!( + let sql = db.sql(&format!( "SELECT m.mid, f.name, f.type, f.size, f.url, m.published FROM message_files f JOIN messages m ON m.mid = f.message_mid - WHERE m.user_id = ? AND (m.jidfrom = ? OR m.jidto = ?) AND m.type IN ({types_sql}) AND m.retracted = 0 + WHERE m.user_id = ? AND (m.jidfrom = ? OR m.jidto = ?) AND m.type IN ({types_sql}) AND m.retracted = FALSE ORDER BY m.published DESC LIMIT ?" - ))) - .bind(user) - .bind(jid) - .bind(jid) - .bind(limit) - .fetch_all(db) - .await?) + )); + Ok(on!(db, p => sqlx::query_as(sql).bind(user).bind(jid).bind(jid).bind(limit).fetch_all(p).await?)) } /// Forget everything stored for an account pub async fn user_delete_data(db: &Db, user: &str) -> Result<()> { - let mut tx = db.begin().await?; - for sql in [ - "DELETE FROM reactions WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ?)", - "DELETE FROM message_files WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ?)", - "DELETE FROM messages WHERE user_id = ?", - "DELETE FROM open_chats WHERE user_id = ?", - "DELETE FROM mam_earliests WHERE user_id = ?", - "DELETE FROM history_clears WHERE user_id = ?", - "DELETE FROM omemo_states WHERE user_id = ?", - "DELETE FROM encrypted_passwords WHERE user_id = ?", - "DELETE FROM push_subscriptions WHERE user_id = ?", - "DELETE FROM users WHERE id = ?", - ] { - sqlx::query(sql).bind(user).execute(&mut *tx).await?; - } - tx.commit().await?; + on!(db, p => { + let mut tx = p.begin().await?; + for sql in [ + "DELETE FROM reactions WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ?)", + "DELETE FROM message_files WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ?)", + "DELETE FROM messages WHERE user_id = ?", + "DELETE FROM open_chats WHERE user_id = ?", + "DELETE FROM mam_earliests WHERE user_id = ?", + "DELETE FROM history_clears WHERE user_id = ?", + "DELETE FROM omemo_states WHERE user_id = ?", + "DELETE FROM encrypted_passwords WHERE user_id = ?", + "DELETE FROM push_subscriptions WHERE user_id = ?", + "DELETE FROM users WHERE id = ?", + ] { + sqlx::query(db.sql(sql)).bind(user).execute(&mut *tx).await?; + } + tx.commit().await?; + }); Ok(()) } pub async fn push_subscriptions(db: &Db, user: &str) -> Result> { - Ok(sqlx::query_as( - "SELECT endpoint, COALESCE(p256dh, ''), COALESCE(auth, '') FROM push_subscriptions WHERE user_id = ? AND enabled = 1", - ) - .bind(user) - .fetch_all(db) - .await?) + let sql = db.sql( + "SELECT endpoint, COALESCE(p256dh, ''), COALESCE(auth, '') FROM push_subscriptions WHERE user_id = ? AND enabled = TRUE", + ); + Ok(on!(db, p => sqlx::query_as(sql).bind(user).fetch_all(p).await?)) } pub async fn push_subscription_delete(db: &Db, endpoint: &str) -> Result<()> { - sqlx::query("DELETE FROM push_subscriptions WHERE endpoint = ?").bind(endpoint).execute(db).await?; + let sql = db.sql("DELETE FROM push_subscriptions WHERE endpoint = ?"); + on!(db, p => sqlx::query(sql).bind(endpoint).execute(p).await?;); Ok(()) } @@ -1226,37 +1423,79 @@ pub struct PushSubscription { } pub async fn push_subscriptions_list(db: &Db, user: &str) -> Result> { - Ok(sqlx::query_as::<_, PushSubscription>( + let sql = db.sql( "SELECT rowid AS id, endpoint, enabled, activity_at FROM push_subscriptions WHERE user_id = ? ORDER BY activity_at DESC", - ) - .bind(user) - .fetch_all(db) - .await?) + ); + Ok(on!(db, p => sqlx::query_as::<_, PushSubscription>(sql).bind(user).fetch_all(p).await?)) } pub async fn push_subscription_toggle(db: &Db, user: &str, id: i64, enabled: bool) -> Result<()> { - sqlx::query("UPDATE push_subscriptions SET enabled = ? WHERE rowid = ? AND user_id = ?") - .bind(enabled) - .bind(id) - .bind(user) - .execute(db) - .await?; + let sql = db.sql("UPDATE push_subscriptions SET enabled = ? WHERE rowid = ? AND user_id = ?"); + on!(db, p => sqlx::query(sql).bind(enabled).bind(id).bind(user).execute(p).await?;); Ok(()) } #[cfg(test)] -mod tests { +pub mod tests { use super::*; fn days_ago(days: i64) -> String { (chrono::Utc::now() - chrono::Duration::days(days)).format("%Y-%m-%d %H:%M:%S").to_string() } + /// A SQLite file: every pool connection to sqlite::memory: gets its own database + pub async fn sqlite_test_db(name: &str) -> (Db, std::path::PathBuf) { + let path = std::env::temp_dir().join(format!("movim-test-{name}-{}.sqlite", std::process::id())); + remove_sqlite(&path); + (connect(&format!("sqlite://{}?mode=rwc", path.display())).await.unwrap(), path) + } + + pub fn remove_sqlite(path: &std::path::Path) { + for ext in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{ext}", path.display())); + } + } + + /// A new database on the server of MOVIM_TEST_POSTGRES_URL + /// (postgres://user:password@host/db), None when it is not set + pub async fn postgres_test_url(name: &str) -> Option { + let Ok(admin) = std::env::var("MOVIM_TEST_POSTGRES_URL") else { + eprintln!("MOVIM_TEST_POSTGRES_URL is not set, skipping the PostgreSQL test {name}"); + return None; + }; + let database = format!("movim_test_{name}_{}", std::process::id()); + let pool = PgPoolOptions::new().max_connections(1).connect(&admin).await.unwrap(); + sqlx::query(AssertSqlSafe(format!("DROP DATABASE IF EXISTS {database} WITH (FORCE)"))).execute(&pool).await.unwrap(); + sqlx::query(AssertSqlSafe(format!("CREATE DATABASE {database}"))).execute(&pool).await.unwrap(); + pool.close().await; + let base = admin.split('?').next().unwrap(); + Some(format!("{}/{database}", &base[..base.rfind('/').unwrap()])) + } + #[tokio::test] - async fn clear_history_is_remembered() { - // A file: every pool connection to sqlite::memory: gets its own database - let path = std::env::temp_dir().join(format!("movim-test-{}.sqlite", std::process::id())); - let db = connect(&format!("sqlite://{}?mode=rwc", path.display())).await.unwrap(); + async fn clear_history_is_remembered_sqlite() { + let (db, path) = sqlite_test_db("clear").await; + clear_history_is_remembered(&db).await; + db.close().await; + remove_sqlite(&path); + } + + #[tokio::test] + async fn clear_history_is_remembered_postgres() { + let Some(url) = postgres_test_url("clear").await else { return }; + let db = connect(&url).await.unwrap(); + clear_history_is_remembered(&db).await; + db.close().await; + } + + #[test] + fn postgres_placeholders() { + assert_eq!(to_postgres("SELECT a FROM t WHERE b = ? AND c LIKE 'x?%' AND d IN (?, ?)"), "SELECT a FROM t WHERE b = $1 AND c LIKE 'x?%' AND d IN ($2, $3)"); + assert!(to_postgres("UPDATE t SET u = CURRENT_TIMESTAMP").contains("to_char(now() AT TIME ZONE 'UTC'")); + } + + async fn clear_history_is_remembered(db: &Db) { + let db = db.clone(); let (me, bob) = ("me@example.org", "bob@example.org"); let m = Message { user_id: me.into(), @@ -1336,9 +1575,39 @@ mod tests { assert_eq!(messages_count(&db, me, bob).await.unwrap(), before - 1); assert!(reactions_for(&db, &[old_mid]).await.unwrap().is_empty()); - db.close().await; - for ext in ["", "-wal", "-shm"] { - let _ = std::fs::remove_file(format!("{}{ext}", path.display())); - } + // Upserts: a message seen once stays seen, open chats keep their latest date + let unseen = Message { id: "u1".into(), published: crate::utils::now_sql(), ..m.clone() }; + let unseen_mid = message_save(&db, &Message { seen: true, ..unseen.clone() }).await.unwrap(); + assert_eq!(message_save(&db, &unseen).await.unwrap(), unseen_mid); + assert!(message_by_mid(&db, me, unseen_mid).await.unwrap().unwrap().seen); + open_chat_touch(&db, me, bob, Some("2026-02-01 10:00:00")).await.unwrap(); + open_chat_touch(&db, me, bob, Some("2026-01-01 10:00:00")).await.unwrap(); + open_chat_touch(&db, me, "carol@example.org", Some("2026-01-15 10:00:00")).await.unwrap(); + // eve is opened too: her message is unread + assert_eq!(open_chats(&db, me).await.unwrap(), ["carol@example.org", bob, "eve@example.org"]); + + // Users, booleans and push subscriptions + let user = user_get_or_create(&db, me).await.unwrap(); + assert!(user.chatmain && !user.nightmode); + user_set(&db, me, "nightmode", &serde_json::json!(true)).await.unwrap(); + assert!(user_get(&db, me).await.unwrap().unwrap().nightmode); + omemo_state_set(&db, me, bob, false).await.unwrap(); + omemo_state_set(&db, me, bob, true).await.unwrap(); + assert_eq!(omemo_state(&db, me, bob).await.unwrap(), Some(true)); + push_subscription_save(&db, me, "https://push.example/1", "k", "a").await.unwrap(); + let subs = push_subscriptions_list(&db, me).await.unwrap(); + push_subscription_toggle(&db, me, subs[0].id, false).await.unwrap(); + assert!(push_subscriptions(&db, me).await.unwrap().is_empty()); + avatar_save(&db, "h", "image/png", &[0, 159, 255]).await.unwrap(); + avatar_save(&db, "h", "image/png", &[1, 2]).await.unwrap(); + assert_eq!(avatar_get(&db, "h").await.unwrap(), Some(("image/png".into(), vec![1, 2]))); + assert_eq!(top_contacts(&db, me, 5).await.unwrap(), [bob, "eve@example.org"]); + assert!(unreads_count(&db, me, bob, false).await.unwrap() > 0); + messages_mark_seen(&db, me, bob).await.unwrap(); + assert_eq!(unreads_count(&db, me, bob, false).await.unwrap(), 0); + + user_delete_data(&db, me).await.unwrap(); + assert_eq!(messages_count(&db, me, bob).await.unwrap(), 0); + assert!(user_get(&db, me).await.unwrap().is_none()); } } diff --git a/src/main.rs b/src/main.rs index 31802a1..d91c566 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod handler; mod i18n; mod jingle; mod mam; +mod migrate_postgres; mod page; mod pep; mod proto; @@ -38,6 +39,13 @@ async fn main() -> anyhow::Result<()> { let _ = tokio_xmpp::rustls::crypto::aws_lc_rs::default_provider().install_default(); let cfg = config::Config::from_env(); + + // movim migrate-to-postgres [--from ] + let args: Vec = std::env::args().skip(1).collect(); + if args.first().map(String::as_str) == Some("migrate-to-postgres") { + return migrate_to_postgres(&cfg, &args[1..]).await; + } + std::fs::create_dir_all(&cfg.data_dir)?; i18n::init(&cfg.frontend); @@ -102,3 +110,32 @@ async fn main() -> anyhow::Result<()> { axum::serve(listener, router).await?; Ok(()) } + +/// Copy the SQLite database (MOVIM_DATABASE_URL, the data dir one by default, or +/// `--from`) to an empty PostgreSQL database. Movim must be stopped meanwhile. +async fn migrate_to_postgres(cfg: &config::Config, args: &[String]) -> anyhow::Result<()> { + let mut to = None; + let mut from = None; + let mut args = args.iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--from" => from = args.next().cloned(), + _ if to.is_none() => to = Some(arg.clone()), + _ => anyhow::bail!("unexpected argument {arg}"), + } + } + let usage = "usage: movim migrate-to-postgres postgres://user:password@host/database [--from sqlite:///path/movim.sqlite]"; + let to = to.ok_or_else(|| anyhow::anyhow!(usage))?; + let from = from.unwrap_or_else(|| cfg.database_url.clone()); + anyhow::ensure!(!db::is_postgres_url(&from), "{usage}\nMOVIM_DATABASE_URL is already PostgreSQL, give the SQLite one with --from"); + if let Some(path) = from.strip_prefix("sqlite://").map(|p| p.split('?').next().unwrap_or(p)) { + anyhow::ensure!(std::path::Path::new(path).exists(), "{path} does not exist"); + } + + tracing::info!("copying {from} to PostgreSQL"); + for (table, rows) in migrate_postgres::sqlite_to_postgres(&from, &to).await? { + println!("{table:>20}: {rows} rows"); + } + println!("Done. Start Movim with MOVIM_DATABASE_URL set to the PostgreSQL URL"); + Ok(()) +} diff --git a/src/migrate_postgres.rs b/src/migrate_postgres.rs new file mode 100644 index 0000000..365ec2e --- /dev/null +++ b/src/migrate_postgres.rs @@ -0,0 +1,327 @@ +//! Copy a SQLite database to PostgreSQL: `movim migrate-to-postgres`. +//! +//! The target must be empty (its tables are created if needed). Everything is +//! copied in one transaction, the ids are kept (replies, reactions and files +//! point to the messages by mid) and the sequences continue after them. + +use anyhow::{Context, Result, bail}; +use sqlx::sqlite::SqliteRow; +use sqlx::{AssertSqlSafe, PgPool, Postgres, QueryBuilder, Row, SqlitePool}; + +use crate::db::{self, Db}; + +#[derive(Clone, Copy)] +enum Kind { + Text, + Int, + Bool, + Blob, +} + +use Kind::*; + +/// The columns of `db::SCHEMA`. push_subscriptions copies the implicit SQLite rowid. +const TABLES: &[(&str, &[(&str, Kind)])] = &[ + ( + "users", + &[ + ("id", Text), + ("nickname", Text), + ("language", Text), + ("nightmode", Bool), + ("chatmain", Bool), + ("notificationchat", Bool), + ("notificationcall", Bool), + ("omemoenabled", Bool), + ("accentcolor", Text), + ("admin", Bool), + ("chats", Text), + ("created_at", Text), + ("updated_at", Text), + ], + ), + ("encrypted_passwords", &[("id", Text), ("user_id", Text), ("data", Text), ("updated_at", Text)]), + ( + "contacts", + &[ + ("id", Text), + ("fn", Text), + ("name", Text), + ("nickname", Text), + ("email", Text), + ("url", Text), + ("description", Text), + ("avatarhash", Text), + ("avatartype", Text), + ("updated_at", Text), + ], + ), + ("avatars", &[("hash", Text), ("mime", Text), ("data", Blob)]), + ( + "messages", + &[ + ("mid", Int), + ("user_id", Text), + ("id", Text), + ("messageid", Text), + ("originid", Text), + ("stanzaid", Text), + ("replaceid", Text), + ("jidto", Text), + ("jidfrom", Text), + ("resource", Text), + ("type", Text), + ("subject", Text), + ("thread", Text), + ("body", Text), + ("html", Text), + ("quoted", Bool), + ("markable", Bool), + ("seen", Bool), + ("delivered", Text), + ("displayed", Text), + ("retracted", Bool), + ("encrypted", Bool), + ("omemoheader", Text), + ("bundleid", Int), + ("parentmid", Int), + ("resolved", Bool), + ("published", Text), + ("created_at", Text), + ("updated_at", Text), + ], + ), + ( + "message_files", + &[ + ("id", Int), + ("message_mid", Int), + ("name", Text), + ("type", Text), + ("size", Int), + ("url", Text), + ("disposition", Text), + ("thumbnail_url", Text), + ("thumbnail_type", Text), + ("thumbnail_width", Int), + ("thumbnail_height", Int), + ], + ), + ("reactions", &[("id", Int), ("message_mid", Int), ("jidfrom", Text), ("emoji", Text), ("created_at", Text)]), + ("mam_earliests", &[("user_id", Text), ("jid", Text), ("earliest", Text)]), + ("history_clears", &[("user_id", Text), ("jid", Text), ("cleared_at", Text)]), + ("omemo_states", &[("user_id", Text), ("jid", Text), ("enabled", Bool)]), + ("open_chats", &[("user_id", Text), ("jid", Text), ("updated_at", Text)]), + ("urls", &[("hash", Text), ("url", Text), ("data", Text), ("created_at", Text)]), + ( + "push_subscriptions", + &[ + ("rowid", Int), + ("endpoint", Text), + ("user_id", Text), + ("p256dh", Text), + ("auth", Text), + ("enabled", Bool), + ("activity_at", Text), + ], + ), +]; + +/// Rows read from SQLite and inserted in PostgreSQL at once (65535 parameters at most) +const BATCH: i64 = 1000; + +/// Copy all the data of `from` (SQLite) into `to` (PostgreSQL), returns the +/// number of rows copied per table +pub async fn sqlite_to_postgres(from: &str, to: &str) -> Result> { + anyhow::ensure!(!db::is_postgres_url(from), "the source must be a SQLite database"); + anyhow::ensure!(db::is_postgres_url(to), "the target must be a postgres:// URL"); + + // Both are opened with `db::connect`: the SQLite tables get the columns + // added since they were created, the PostgreSQL ones are created + let Db::Sqlite(src) = db::connect(from).await.context("opening the SQLite database")? else { unreachable!() }; + let Db::Postgres(dst) = db::connect(to).await.context("opening the PostgreSQL database")? else { unreachable!() }; + + for (table, _) in TABLES { + let n: i64 = sqlx::query_scalar(AssertSqlSafe(format!("SELECT COUNT(*) FROM {table}"))).fetch_one(&dst).await?; + if n > 0 { + bail!("the PostgreSQL table {table} is not empty ({n} rows), migrate into an empty database"); + } + } + + let report = copy_all(&src, &dst).await; + src.close().await; + dst.close().await; + report +} + +async fn copy_all(src: &SqlitePool, dst: &PgPool) -> Result> { + let mut tx = dst.begin().await?; + let mut report = vec![]; + + for (table, columns) in TABLES { + let copied = copy_table(src, &mut tx, table, columns).await.with_context(|| format!("copying {table}"))?; + let expected: i64 = sqlx::query_scalar(AssertSqlSafe(format!("SELECT COUNT(*) FROM {table}"))).fetch_one(src).await?; + anyhow::ensure!(copied == expected as u64, "{table}: {copied} rows copied out of {expected}"); + report.push((*table, copied)); + } + + for (table, column) in db::IDENTITY_COLUMNS { + sqlx::query(AssertSqlSafe(format!( + "SELECT setval(pg_get_serial_sequence('{table}', '{column}'), COALESCE(MAX({column}), 0) + 1, false) FROM {table}" + ))) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(report) +} + +async fn copy_table( + src: &SqlitePool, + tx: &mut sqlx::Transaction<'_, Postgres>, + table: &str, + columns: &[(&str, Kind)], +) -> Result { + // SQLite does not enforce the column types: cast them to what PostgreSQL expects + let select = columns + .iter() + .map(|(name, kind)| match kind { + Text => format!("CAST(\"{name}\" AS TEXT)"), + Int | Bool => format!("CAST(\"{name}\" AS INTEGER)"), + Blob => format!("\"{name}\""), + }) + .collect::>() + .join(", "); + let names = columns.iter().map(|(name, _)| format!("\"{name}\"")).collect::>().join(", "); + + let mut copied = 0; + let mut last_rowid = i64::MIN; + loop { + let rows: Vec = sqlx::query(AssertSqlSafe(format!( + "SELECT rowid, {select} FROM {table} WHERE rowid > ? ORDER BY rowid LIMIT ?" + ))) + .bind(last_rowid) + .bind(BATCH) + .fetch_all(src) + .await?; + let Some(last) = rows.last() else { break }; + last_rowid = last.try_get(0)?; + + let mut insert = QueryBuilder::::new(format!("INSERT INTO {table} ({names}) ")); + let mut error = None; + insert.push_values(&rows, |mut values, row| { + for (i, (name, kind)) in columns.iter().enumerate() { + let i = i + 1; + let pushed = match kind { + Text => row.try_get::, _>(i).map(|v| { + values.push_bind(v); + }), + Int => row.try_get::, _>(i).map(|v| { + values.push_bind(v); + }), + Bool => row.try_get::, _>(i).map(|v| { + values.push_bind(v.map(|v| v != 0)); + }), + Blob => row.try_get::>, _>(i).map(|v| { + values.push_bind(v); + }), + }; + if let Err(e) = pushed { + error.get_or_insert(anyhow::anyhow!("column {name}: {e}")); + values.push_bind(None::); + } + } + }); + if let Some(e) = error { + return Err(e); + } + copied += insert.build().execute(&mut **tx).await?.rows_affected(); + } + Ok(copied) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::tests::{postgres_test_url, remove_sqlite, sqlite_test_db}; + use crate::db::{Message, MessageFile}; + + #[tokio::test] + async fn copies_everything() { + let Some(to) = postgres_test_url("migrate").await else { return }; + let (src, path) = sqlite_test_db("migrate").await; + let (me, bob) = ("me@example.org", "bob@example.org"); + + db::user_get_or_create(&src, me).await.unwrap(); + db::user_set(&src, me, "nightmode", &serde_json::json!(true)).await.unwrap(); + db::encrypted_password_set(&src, me, "device", "secret").await.unwrap(); + db::contact_save(&src, &db::Contact { fn_: Some("Bob".into()), ..db::Contact::new(bob) }).await.unwrap(); + db::avatar_save(&src, "hash", "image/png", &[0, 1, 254, 255]).await.unwrap(); + let m = Message { + user_id: me.into(), + id: "m1".into(), + jidfrom: bob.into(), + jidto: me.into(), + type_: "chat".into(), + body: Some("xin chào 👋".into()), + published: "2026-01-01 10:00:00".into(), + ..Default::default() + }; + // A gap in the mids: the copy keeps them + let first = db::message_save(&src, &m).await.unwrap(); + let gone = db::message_save(&src, &Message { id: "m2".into(), ..m.clone() }).await.unwrap(); + let reply = Message { id: "m3".into(), parentmid: Some(first), encrypted: true, omemoheader: Some("{}".into()), ..m.clone() }; + let reply = db::message_save(&src, &reply).await.unwrap(); + let Db::Sqlite(pool) = &src else { unreachable!() }; + sqlx::query("DELETE FROM messages WHERE mid = ?").bind(gone).execute(pool).await.unwrap(); + db::message_file_save(&src, &MessageFile { message_mid: reply, url: "https://f".into(), size: Some(42), ..Default::default() }) + .await + .unwrap(); + db::reactions_replace(&src, first, me, &["👍".into()]).await.unwrap(); + db::mam_earliest_set(&src, me, bob, None).await.unwrap(); + db::omemo_state_set(&src, me, bob, true).await.unwrap(); + db::open_chat_touch(&src, me, bob, None).await.unwrap(); + db::url_save(&src, "u", "https://example.org", "{}").await.unwrap(); + db::push_subscription_save(&src, me, "https://push.example/1", "k", "a").await.unwrap(); + db::push_subscription_save(&src, me, "https://push.example/2", "k", "a").await.unwrap(); + let subs = db::push_subscriptions_list(&src, me).await.unwrap(); + db::push_subscription_toggle(&src, me, subs[0].id, false).await.unwrap(); + let from = format!("sqlite://{}", path.display()); + src.close().await; + + let report = sqlite_to_postgres(&from, &to).await.unwrap(); + let count = |t: &str| report.iter().find(|(name, _)| *name == t).unwrap().1; + assert_eq!((count("messages"), count("reactions"), count("push_subscriptions")), (2, 1, 2)); + + let dst = db::connect(&to).await.unwrap(); + assert!(db::user_get(&dst, me).await.unwrap().unwrap().nightmode); + assert_eq!(db::encrypted_password_get(&dst, me, "device").await.unwrap().as_deref(), Some("secret")); + assert_eq!(db::contact_get(&dst, bob).await.unwrap().unwrap().truename(), "Bob"); + assert_eq!(db::avatar_get(&dst, "hash").await.unwrap().unwrap().1, vec![0, 1, 254, 255]); + let copied = db::message_by_mid(&dst, me, reply).await.unwrap().unwrap(); + assert_eq!((copied.parentmid, copied.encrypted, copied.body.as_deref()), (Some(first), true, Some("xin chào 👋"))); + assert_eq!(db::message_file(&dst, reply).await.unwrap().unwrap().size, Some(42)); + assert_eq!(db::reactions_for(&dst, &[first]).await.unwrap().len(), 1); + assert!(db::mam_earliest_exists(&dst, me, bob).await.unwrap()); + assert_eq!(db::omemo_state(&dst, me, bob).await.unwrap(), Some(true)); + assert_eq!(db::open_chats(&dst, me).await.unwrap(), vec![bob.to_string()]); + assert_eq!(db::url_get(&dst, "u").await.unwrap().as_deref(), Some("{}")); + let copied_subs = db::push_subscriptions_list(&dst, me).await.unwrap(); + assert_eq!(copied_subs.iter().map(|s| (s.id, s.enabled)).collect::>(), subs.iter().map(|s| (s.id, s.id != subs[0].id)).collect::>()); + assert_eq!(db::push_subscriptions(&dst, me).await.unwrap().len(), 1); + + // The sequences continue after the copied ids + let next = db::message_save(&dst, &Message { id: "m4".into(), ..m.clone() }).await.unwrap(); + assert!(next > reply); + db::push_subscription_save(&dst, me, "https://push.example/3", "k", "a").await.unwrap(); + db::reactions_replace(&dst, next, me, &["❤️".into()]).await.unwrap(); + + // A second migration does not duplicate the data + let again = sqlite_to_postgres(&from, &to).await.unwrap_err().to_string(); + assert!(again.contains("not empty"), "{again}"); + + dst.close().await; + remove_sqlite(&path); + } +} diff --git a/src/widgets/chats.rs b/src/widgets/chats.rs index f0ced9a..aa6ca8b 100644 --- a/src/widgets/chats.rs +++ b/src/widgets/chats.rs @@ -23,6 +23,15 @@ fn item_id(jid: &str) -> String { format!("#{}", utils::cleanup_id(&format!("{}_chat_item", utils::slugify(jid)))) } +/// Put the item of a discussion on top of the list. The html is prepared before +/// touching the list: with an await between the remove and the prepend, two +/// concurrent calls (a message during a reload) left two items for one contact. +async fn put_on_top(ctx: &Ctx, jid: &str) { + let html = prepare_chat(ctx, jid).await; + rpc!(ctx, "MovimTpl.remove", item_id(jid)); + rpc!(ctx, "MovimTpl.prepend", "#chats", html); +} + pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool { match func { "ajaxHttpGet" => { @@ -138,7 +147,7 @@ pub async fn chat_open(ctx: &Ctx, jid: &str) { let me = ctx.jid(); if !db::open_chat_exists(ctx.db(), &me, jid).await.unwrap_or(false) { let _ = db::open_chat_touch(ctx.db(), &me, jid, None).await; - rpc!(ctx, "MovimTpl.prepend", "#chats", prepare_chat(ctx, jid).await); + put_on_top(ctx, jid).await; } else { rpc!(ctx, "MovimTpl.replace", item_id(jid), prepare_chat(ctx, jid).await); } @@ -170,8 +179,7 @@ pub async fn open(ctx: &Ctx, jid: &str, and_show: bool, history: bool) { }); } let _ = db::open_chat_touch(ctx.db(), &me, jid, None).await; - rpc!(ctx, "MovimTpl.remove", item_id(jid)); - rpc!(ctx, "MovimTpl.prepend", "#chats", prepare_chat(ctx, jid).await); + put_on_top(ctx, jid).await; rpc!(ctx, "Chats.refresh"); if notif::is_current(ctx, jid) || and_show { rpc!(ctx, "Chats.setActive", jid); @@ -209,8 +217,7 @@ pub async fn on_message(ctx: &Ctx, m: &db::Message, origin: &Origin) { return; } let _ = db::open_chat_touch(ctx.db(), &me, &jid, Some(&m.published)).await; - rpc!(ctx, "MovimTpl.remove", item_id(&jid)); - rpc!(ctx, "MovimTpl.prepend", "#chats", prepare_chat(ctx, &jid).await); + put_on_top(ctx, &jid).await; rpc!(ctx, "Chats.refresh"); if notif::is_current(ctx, &jid) { rpc!(ctx, "Chats.setActive", jid.clone()); diff --git a/tests/e2e/__pycache__/browser_edit.cpython-313.pyc b/tests/e2e/__pycache__/browser_edit.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a9aae4588f0e149befc474977b4dc500f6b1f61d GIT binary patch literal 8364 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 div.message')].slice(-3).map(m => m.id)")) + print("from history, alice:", await bubbles(page, 3)) + await page.screenshot(path=f"{OUT}/history_alice.png") + + for e in aerr: + print(e) + await browser.close() + +asyncio.run(main()) diff --git a/tests/e2e/browser_omemo_restore.py b/tests/e2e/browser_omemo_restore.py new file mode 100644 index 0000000..8baa726 --- /dev/null +++ b/tests/e2e/browser_omemo_restore.py @@ -0,0 +1,58 @@ +"""OMEMO key backup restored in another browser: the decrypted messages come with it, +including our own (edited) ones that are never encrypted for the sending device.""" +import asyncio +from playwright.async_api import async_playwright +from browser import login, BASE, OUT +from browser_edit import send, edit_last, bubbles, open_chat + + +async def main(): + async with async_playwright() as p: + browser = await p.chromium.launch() + actx, alice, aerr = await login(browser, "alice@localhost", "alicepass", "restorealice") + bctx, bob, berr = await login(browser, "bob@localhost", "bobpass", "restorebob") + await alice.wait_for_timeout(4000) + await open_chat(alice, "bob@localhost") + await open_chat(bob, "alice@localhost") + + await alice.evaluate("ChatOmemo.enableContactState('bob@localhost', false)") + await alice.wait_for_timeout(5000) + await send(alice, "Khôi phục: bản gốc") + await edit_last(alice, "Khôi phục: đã sửa") + await send(bob, "Khôi phục: trả lời của bob") + print("before, alice:", await bubbles(alice, 2)) + + # Backup + await alice.goto(BASE + "/configuration") + await alice.wait_for_timeout(3000) + await alice.evaluate("Account_ajaxOmemoBackup()") + await alice.wait_for_selector("form[name=omemo_backup] input[name=password]", timeout=10000) + await alice.fill("form[name=omemo_backup] input[name=password]", "secret") + await alice.fill("form[name=omemo_backup] input[name=password_confirmation]", "secret") + async with alice.expect_download() as dl: + await alice.evaluate("Account.omemoBackup()") + path = f"{OUT}/restore_backup.json" + await (await dl.value).save_as(path) + + # Restore in a fresh browser + ctx2, alice2, a2err = await login(browser, "alice@localhost", "alicepass", "restorealice2") + await alice2.wait_for_timeout(4000) + await alice2.goto(BASE + "/configuration") + await alice2.wait_for_timeout(3000) + await alice2.evaluate("Account_ajaxOmemoRestore()") + await alice2.wait_for_selector("form[name=omemo_restore] input[name=file]", timeout=10000) + await alice2.set_input_files("form[name=omemo_restore] input[name=file]", path) + await alice2.fill("form[name=omemo_restore] input[name=password]", "secret") + async with alice2.expect_navigation(): + await alice2.evaluate("Account.omemoRestore()") + await alice2.wait_for_timeout(4000) + + await open_chat(alice2, "bob@localhost") + print("restored, alice:", await bubbles(alice2, 2)) + await alice2.screenshot(path=f"{OUT}/restore_alice2.png") + + for e in aerr + berr + a2err: + print(e) + await browser.close() + +asyncio.run(main())