Compare commits
3
Commits
v0.1.0
...
bcaddcbcbb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcaddcbcbb | ||
|
|
5f37fcfe7a | ||
|
|
1ce93d883b |
+1
-1
@@ -30,7 +30,7 @@ serde = { version = "1.0.229", features = ["derive"] }
|
|||||||
serde_json = { version = "1.0.154", features = ["preserve_order"] }
|
serde_json = { version = "1.0.154", features = ["preserve_order"] }
|
||||||
sha1 = "0.11.0"
|
sha1 = "0.11.0"
|
||||||
sha2 = "0.11.1"
|
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 = { version = "1", features = ["full"] }
|
||||||
tokio-xmpp = { version = "6", features = ["insecure-tcp"] }
|
tokio-xmpp = { version = "6", features = ["insecure-tcp"] }
|
||||||
tower-http = { version = "0.7", features = ["fs"] }
|
tower-http = { version = "0.7", features = ["fs"] }
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
A rewrite of the [Movim](https://movim.eu) XMPP web client backend in Rust, keeping
|
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
|
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,
|
templates were replaced by a single Rust server (tokio, axum, tokio-xmpp, sqlx with SQLite
|
||||||
minijinja). Scope: a Conversations-like chat client.
|
or PostgreSQL, minijinja). Scope: a Conversations-like chat client.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
1:1 chat, group chats (MUC, bookmarks, invitations, moderation, configuration),
|
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`,
|
dev only: `MOVIM_XMPP_PLAINTEXT=host:port`, `MOVIM_ALLOW_PRIVATE_URLS=1`,
|
||||||
`MOVIM_DEBUG_XMPP=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
|
## 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
|
drive the server (and the real frontend in headless Chromium) against a local
|
||||||
Prosody; see `tests/e2e/README.md`.
|
Prosody; see `tests/e2e/README.md`.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -76,10 +76,13 @@ var Account = {
|
|||||||
Object.keys(localStorage).filter(k => k.startsWith(prefix)).forEach(k => keys[k] = localStorage.getItem(k));
|
Object.keys(localStorage).filter(k => k.startsWith(prefix)).forEach(k => keys[k] = localStorage.getItem(k));
|
||||||
let deviceId = await store.getLocalRegistrationId();
|
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({
|
let payload = JSON.stringify({
|
||||||
jid: store.jid,
|
jid: store.jid,
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
keys: keys
|
keys: keys,
|
||||||
|
messages: await ChatOmemoDB.getAllMessages()
|
||||||
});
|
});
|
||||||
|
|
||||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
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.keys(localStorage).filter(k => k.startsWith(prefix)).forEach(k => localStorage.removeItem(k));
|
||||||
Object.entries(payload.keys).forEach(([k, v]) => localStorage.setItem(k, v));
|
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 });
|
localStorage.setObject(OMEMO_RESTORED_KEY, { replaced: replaced != payload.deviceId ? replaced ?? null : null });
|
||||||
|
|
||||||
// The page reload publishes the restored key (ChatOmemo.ownDevicesReceived)
|
// The page reload publishes the restored key (ChatOmemo.ownDevicesReceived)
|
||||||
|
|||||||
@@ -272,12 +272,29 @@ var ChatOmemo = {
|
|||||||
|
|
||||||
var store = new ChatOmemoStorage();
|
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.messageid
|
||||||
: message.id;
|
: 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);
|
let maybeDecrypted = await ChatOmemoDB.getMessage(resolvedId);
|
||||||
|
|
||||||
|
if (typeof maybeDecrypted == 'string' && stableId) {
|
||||||
|
ChatOmemoDB.putMessage(stableId, maybeDecrypted);
|
||||||
|
}
|
||||||
|
|
||||||
if (maybeDecrypted !== undefined) {
|
if (maybeDecrypted !== undefined) {
|
||||||
return maybeDecrypted;
|
return maybeDecrypted;
|
||||||
}
|
}
|
||||||
@@ -369,6 +386,7 @@ var ChatOmemo = {
|
|||||||
let plaintext = MovimUtils.arrayBufferToString(decryptedBuffer);
|
let plaintext = MovimUtils.arrayBufferToString(decryptedBuffer);
|
||||||
|
|
||||||
ChatOmemoDB.putMessage(resolvedId, plaintext);
|
ChatOmemoDB.putMessage(resolvedId, plaintext);
|
||||||
|
if (stableId) ChatOmemoDB.putMessage(stableId, plaintext);
|
||||||
return plaintext;
|
return plaintext;
|
||||||
},
|
},
|
||||||
enableContactState: function (jid, muc) {
|
enableContactState: function (jid, muc) {
|
||||||
|
|||||||
@@ -65,6 +65,41 @@ var ChatOmemoDB = {
|
|||||||
return promise;
|
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) {
|
getMessage: async function(id) {
|
||||||
let promise = new Promise(resolve => {
|
let promise = new Promise(resolve => {
|
||||||
if (id in ChatOmemoDB.cachedMessages) {
|
if (id in ChatOmemoDB.cachedMessages) {
|
||||||
|
|||||||
+37
@@ -9,6 +9,7 @@ mod handler;
|
|||||||
mod i18n;
|
mod i18n;
|
||||||
mod jingle;
|
mod jingle;
|
||||||
mod mam;
|
mod mam;
|
||||||
|
mod migrate_postgres;
|
||||||
mod page;
|
mod page;
|
||||||
mod pep;
|
mod pep;
|
||||||
mod proto;
|
mod proto;
|
||||||
@@ -38,6 +39,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let _ = tokio_xmpp::rustls::crypto::aws_lc_rs::default_provider().install_default();
|
let _ = tokio_xmpp::rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
|
|
||||||
let cfg = config::Config::from_env();
|
let cfg = config::Config::from_env();
|
||||||
|
|
||||||
|
// movim migrate-to-postgres <postgres://…> [--from <sqlite://…>]
|
||||||
|
let args: Vec<String> = 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)?;
|
std::fs::create_dir_all(&cfg.data_dir)?;
|
||||||
|
|
||||||
i18n::init(&cfg.frontend);
|
i18n::init(&cfg.frontend);
|
||||||
@@ -102,3 +110,32 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
axum::serve(listener, router).await?;
|
axum::serve(listener, router).await?;
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<Vec<(&'static str, u64)>> {
|
||||||
|
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<Vec<(&'static str, u64)>> {
|
||||||
|
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<u64> {
|
||||||
|
// 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::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let names = columns.iter().map(|(name, _)| format!("\"{name}\"")).collect::<Vec<_>>().join(", ");
|
||||||
|
|
||||||
|
let mut copied = 0;
|
||||||
|
let mut last_rowid = i64::MIN;
|
||||||
|
loop {
|
||||||
|
let rows: Vec<SqliteRow> = 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::<Postgres>::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::<Option<String>, _>(i).map(|v| {
|
||||||
|
values.push_bind(v);
|
||||||
|
}),
|
||||||
|
Int => row.try_get::<Option<i64>, _>(i).map(|v| {
|
||||||
|
values.push_bind(v);
|
||||||
|
}),
|
||||||
|
Bool => row.try_get::<Option<i64>, _>(i).map(|v| {
|
||||||
|
values.push_bind(v.map(|v| v != 0));
|
||||||
|
}),
|
||||||
|
Blob => row.try_get::<Option<Vec<u8>>, _>(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::<String>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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::<Vec<_>>(), subs.iter().map(|s| (s.id, s.id != subs[0].id)).collect::<Vec<_>>());
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -230,8 +230,7 @@ async fn prepare_empty(ctx: &Ctx) -> String {
|
|||||||
|
|
||||||
/// Open a 1:1 discussion (or the empty view)
|
/// Open a 1:1 discussion (or the empty view)
|
||||||
pub async fn get(ctx: &Ctx, jid: Option<&str>, light: bool) {
|
pub async fn get(ctx: &Ctx, jid: Option<&str>, light: bool) {
|
||||||
let me = ctx.jid();
|
match jid.filter(|j| !j.is_empty()) {
|
||||||
match jid.filter(|j| !j.is_empty() && *j != me) {
|
|
||||||
None => {
|
None => {
|
||||||
rpc!(ctx, "MovimTpl.hidePanel");
|
rpc!(ctx, "MovimTpl.hidePanel");
|
||||||
rpc!(ctx, "Notif.current", "chat");
|
rpc!(ctx, "Notif.current", "chat");
|
||||||
|
|||||||
+14
-7
@@ -23,6 +23,15 @@ fn item_id(jid: &str) -> String {
|
|||||||
format!("#{}", utils::cleanup_id(&format!("{}_chat_item", utils::slugify(jid))))
|
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 {
|
pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
|
||||||
match func {
|
match func {
|
||||||
"ajaxHttpGet" => {
|
"ajaxHttpGet" => {
|
||||||
@@ -64,7 +73,7 @@ pub async fn prepare_chats(ctx: &Ctx) -> String {
|
|||||||
let chats = db::open_chats(ctx.db(), &me).await.unwrap_or_default();
|
let chats = db::open_chats(ctx.db(), &me).await.unwrap_or_default();
|
||||||
let mut html = String::new();
|
let mut html = String::new();
|
||||||
for jid in chats.iter().rev() {
|
for jid in chats.iter().rev() {
|
||||||
if utils::validate_jid(jid) && *jid != me {
|
if utils::validate_jid(jid) {
|
||||||
html.push_str(&prepare_chat(ctx, jid).await);
|
html.push_str(&prepare_chat(ctx, jid).await);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +147,7 @@ pub async fn chat_open(ctx: &Ctx, jid: &str) {
|
|||||||
let me = ctx.jid();
|
let me = ctx.jid();
|
||||||
if !db::open_chat_exists(ctx.db(), &me, jid).await.unwrap_or(false) {
|
if !db::open_chat_exists(ctx.db(), &me, jid).await.unwrap_or(false) {
|
||||||
let _ = db::open_chat_touch(ctx.db(), &me, jid, None).await;
|
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 {
|
} else {
|
||||||
rpc!(ctx, "MovimTpl.replace", item_id(jid), prepare_chat(ctx, jid).await);
|
rpc!(ctx, "MovimTpl.replace", item_id(jid), prepare_chat(ctx, jid).await);
|
||||||
}
|
}
|
||||||
@@ -159,7 +168,7 @@ pub async fn refresh_item(ctx: &Ctx, jid: &str) {
|
|||||||
|
|
||||||
pub async fn open(ctx: &Ctx, jid: &str, and_show: bool, history: bool) {
|
pub async fn open(ctx: &Ctx, jid: &str, and_show: bool, history: bool) {
|
||||||
let me = ctx.jid();
|
let me = ctx.jid();
|
||||||
if !utils::validate_jid(jid) || jid == me {
|
if !utils::validate_jid(jid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if history {
|
if history {
|
||||||
@@ -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;
|
let _ = db::open_chat_touch(ctx.db(), &me, jid, None).await;
|
||||||
rpc!(ctx, "MovimTpl.remove", item_id(jid));
|
put_on_top(ctx, jid).await;
|
||||||
rpc!(ctx, "MovimTpl.prepend", "#chats", prepare_chat(ctx, jid).await);
|
|
||||||
rpc!(ctx, "Chats.refresh");
|
rpc!(ctx, "Chats.refresh");
|
||||||
if notif::is_current(ctx, jid) || and_show {
|
if notif::is_current(ctx, jid) || and_show {
|
||||||
rpc!(ctx, "Chats.setActive", jid);
|
rpc!(ctx, "Chats.setActive", jid);
|
||||||
@@ -209,8 +217,7 @@ pub async fn on_message(ctx: &Ctx, m: &db::Message, origin: &Origin) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = db::open_chat_touch(ctx.db(), &me, &jid, Some(&m.published)).await;
|
let _ = db::open_chat_touch(ctx.db(), &me, &jid, Some(&m.published)).await;
|
||||||
rpc!(ctx, "MovimTpl.remove", item_id(&jid));
|
put_on_top(ctx, &jid).await;
|
||||||
rpc!(ctx, "MovimTpl.prepend", "#chats", prepare_chat(ctx, &jid).await);
|
|
||||||
rpc!(ctx, "Chats.refresh");
|
rpc!(ctx, "Chats.refresh");
|
||||||
if notif::is_current(ctx, &jid) {
|
if notif::is_current(ctx, &jid) {
|
||||||
rpc!(ctx, "Chats.setActive", jid.clone());
|
rpc!(ctx, "Chats.setActive", jid.clone());
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ pub async fn call(ctx: &Ctx, widget: &str, func: &str, p: &P<'_>) -> bool {
|
|||||||
("Search", "ajaxSearch") => {
|
("Search", "ajaxSearch") => {
|
||||||
let key = p.str(0).replace("xmpp:", "").trim().to_string();
|
let key = p.str(0).replace("xmpp:", "").trim().to_string();
|
||||||
let key = key.split('?').next().unwrap_or("").to_string();
|
let key = key.split('?').next().unwrap_or("").to_string();
|
||||||
let me = ctx.jid();
|
let html = if utils::validate_bare_jid(&key) && key.contains('@') {
|
||||||
let html = if utils::validate_bare_jid(&key) && key.contains('@') && key != me {
|
|
||||||
let picture = ctx.placeholder_url(&key);
|
let picture = ctx.placeholder_url(&key);
|
||||||
ctx.render("Search/_search_results.html", json!({ "jid": key, "picture": picture }))
|
ctx.render("Search/_search_results.html", json!({ "jid": key, "picture": picture }))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
<span class="primary icon bubble {% if contact.presence %}status {{ contact.presence }}{% endif %}">
|
<span class="primary icon bubble {% if contact.presence %}status {{ contact.presence }}{% endif %}">
|
||||||
<img src="{{ contact.picture }}">
|
<img src="{{ contact.picture }}">
|
||||||
</span>
|
</span>
|
||||||
|
<span class="control icon active divided" onclick="Search.chat('{{ contact.id|echapJS }}', false); Drawer.clear();">
|
||||||
|
<i class="material-symbols">comment</i>
|
||||||
|
</span>
|
||||||
{% if not mine %}
|
{% if not mine %}
|
||||||
<span class="control icon active divided" onclick="Search.chat('{{ contact.id|echapJS }}', false); Drawer.clear();">
|
|
||||||
<i class="material-symbols">comment</i>
|
|
||||||
</span>
|
|
||||||
{% if call_audio %}
|
{% if call_audio %}
|
||||||
<span title="{{ __('button.audio_call') }}" class="control icon active"
|
<span title="{{ __('button.audio_call') }}" class="control icon active"
|
||||||
onclick="Visio_ajaxGetLobby('{{ call_audio|echapJS }}', true); Drawer.clear();">
|
onclick="Visio_ajaxGetLobby('{{ call_audio|echapJS }}', true); Drawer.clear();">
|
||||||
@@ -77,9 +77,9 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{% if not mine %}
|
|
||||||
<ul class="list middle active">
|
<ul class="list middle active">
|
||||||
{% if contact.in_roster %}
|
{% if mine %}
|
||||||
|
{% elif contact.in_roster %}
|
||||||
<li onclick="Notifications_ajaxEditContact('{{ contact.id|echapJS }}'); Drawer.clear();">
|
<li onclick="Notifications_ajaxEditContact('{{ contact.id|echapJS }}'); Drawer.clear();">
|
||||||
<span class="primary icon gray">
|
<span class="primary icon gray">
|
||||||
<i class="material-symbols">edit</i>
|
<i class="material-symbols">edit</i>
|
||||||
@@ -106,7 +106,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if contact.blocked %}
|
{% if mine %}
|
||||||
|
{% elif contact.blocked %}
|
||||||
<li onclick="ContactActions_ajaxUnblock('{{ contact.id|echapJS }}'); Drawer.clear();">
|
<li onclick="ContactActions_ajaxUnblock('{{ contact.id|echapJS }}'); Drawer.clear();">
|
||||||
<span class="primary icon green">
|
<span class="primary icon green">
|
||||||
<i class="material-symbols">cancel</i>
|
<i class="material-symbols">cancel</i>
|
||||||
@@ -144,7 +145,6 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if resources %}
|
{% if resources %}
|
||||||
<ul class="list middle">
|
<ul class="list middle">
|
||||||
|
|||||||
Binary file not shown.
@@ -87,4 +87,5 @@ async def main():
|
|||||||
print(e)
|
print(e)
|
||||||
await browser.close()
|
await browser.close()
|
||||||
|
|
||||||
asyncio.run(main())
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Own OMEMO messages coming back from the archive (MAM) are shown decrypted, like when edited:
|
||||||
|
their local id is m_…, the browser stored the plaintext under the id it sent them with."""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
from browser import login, BASE, OUT
|
||||||
|
from browser_edit import send, edit_last, bubbles, open_chat
|
||||||
|
|
||||||
|
D = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch()
|
||||||
|
actx, alice, aerr = await login(browser, "alice@localhost", "alicepass", "historyalice")
|
||||||
|
await alice.wait_for_timeout(4000)
|
||||||
|
await open_chat(alice, "bob@localhost")
|
||||||
|
await alice.evaluate("ChatOmemo.enableContactState('bob@localhost', false)")
|
||||||
|
await alice.wait_for_timeout(5000)
|
||||||
|
await send(alice, "Lịch sử: bản gốc")
|
||||||
|
await edit_last(alice, "Lịch sử: đã sửa")
|
||||||
|
await send(alice, "Lịch sử: tin thứ hai")
|
||||||
|
|
||||||
|
bctx, bob, berr = await login(browser, "bob@localhost", "bobpass", "historybob")
|
||||||
|
await bob.wait_for_timeout(4000)
|
||||||
|
await open_chat(bob, "alice@localhost")
|
||||||
|
await bob.evaluate("ChatOmemo.enableContactState('alice@localhost', false)")
|
||||||
|
await bob.wait_for_timeout(5000)
|
||||||
|
await send(bob, "Lịch sử: trả lời của bob")
|
||||||
|
await alice.wait_for_timeout(3000)
|
||||||
|
print("before, alice:", await bubbles(alice, 3))
|
||||||
|
await bctx.close()
|
||||||
|
|
||||||
|
# The server forgets the discussion, the history comes back from MAM
|
||||||
|
subprocess.run(["kill", open(f"{D}/server.pid").read().strip()])
|
||||||
|
await alice.wait_for_timeout(1000)
|
||||||
|
db = sqlite3.connect(f"{D}/data/movim.sqlite")
|
||||||
|
for table in ("messages", "mam_earliests"):
|
||||||
|
db.execute(f"DELETE FROM {table} WHERE user_id = 'alice@localhost'")
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
subprocess.run([f"{D}/run.sh"])
|
||||||
|
|
||||||
|
# Same browser context: the decrypted messages of IndexedDB are kept
|
||||||
|
page = await actx.new_page()
|
||||||
|
await page.goto(BASE + "/login")
|
||||||
|
await page.wait_for_timeout(3000)
|
||||||
|
if "/login" in page.url:
|
||||||
|
await page.wait_for_selector("#login_widget input[type=submit]:not([disabled])", timeout=15000)
|
||||||
|
await page.fill("input#username", "alice@localhost")
|
||||||
|
await page.fill("input#password", "alicepass")
|
||||||
|
await page.click("#login_widget input[type=submit]")
|
||||||
|
await page.wait_for_url("**/chat**", timeout=30000)
|
||||||
|
await page.wait_for_timeout(4000)
|
||||||
|
await open_chat(page, "bob@localhost")
|
||||||
|
await page.wait_for_timeout(4000)
|
||||||
|
print("ids:", await page.evaluate(
|
||||||
|
"[...document.querySelectorAll('#chat_widget .bubble > 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())
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user