Compare commits

...
3 Commits
Author SHA1 Message Date
nnduc bcaddcbcbb notes to self (ui) 2026-10-29 13:31:09 +07:00
nnduc 5f37fcfe7a notes to self 2026-10-29 13:17:40 +07:00
nnduc 1ce93d883b omemo keyring 2026-10-27 23:49:59 +07:00
19 changed files with 1392 additions and 499 deletions
+1 -1
View File
@@ -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"] }
+17 -3
View File
@@ -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`.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13 -1
View File
@@ -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)
+19 -1
View File
@@ -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) {
@@ -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) {
+656 -338
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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 <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)?;
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(())
}
+327
View File
@@ -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
View File
@@ -230,8 +230,7 @@ async fn prepare_empty(ctx: &Ctx) -> String {
/// Open a 1:1 discussion (or the empty view)
pub async fn get(ctx: &Ctx, jid: Option<&str>, light: bool) {
let me = ctx.jid();
match jid.filter(|j| !j.is_empty() && *j != me) {
match jid.filter(|j| !j.is_empty()) {
None => {
rpc!(ctx, "MovimTpl.hidePanel");
rpc!(ctx, "Notif.current", "chat");
+14 -7
View File
@@ -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" => {
@@ -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 mut html = String::new();
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);
}
}
@@ -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);
}
@@ -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) {
let me = ctx.jid();
if !utils::validate_jid(jid) || jid == me {
if !utils::validate_jid(jid) {
return;
}
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;
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());
+1 -2
View File
@@ -23,8 +23,7 @@ pub async fn call(ctx: &Ctx, widget: &str, func: &str, p: &P<'_>) -> bool {
("Search", "ajaxSearch") => {
let key = p.str(0).replace("xmpp:", "").trim().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('@') && key != me {
let html = if utils::validate_bare_jid(&key) && key.contains('@') {
let picture = ctx.placeholder_url(&key);
ctx.render("Search/_search_results.html", json!({ "jid": key, "picture": picture }))
} else {
@@ -7,10 +7,10 @@
<span class="primary icon bubble {% if contact.presence %}status {{ contact.presence }}{% endif %}">
<img src="{{ contact.picture }}">
</span>
{% 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 not mine %}
{% if call_audio %}
<span title="{{ __('button.audio_call') }}" class="control icon active"
onclick="Visio_ajaxGetLobby('{{ call_audio|echapJS }}', true); Drawer.clear();">
@@ -77,9 +77,9 @@
</li>
</ul>
{% if not mine %}
<ul class="list middle active">
{% if contact.in_roster %}
{% if mine %}
{% elif contact.in_roster %}
<li onclick="Notifications_ajaxEditContact('{{ contact.id|echapJS }}'); Drawer.clear();">
<span class="primary icon gray">
<i class="material-symbols">edit</i>
@@ -106,7 +106,8 @@
</div>
</li>
{% endif %}
{% if contact.blocked %}
{% if mine %}
{% elif contact.blocked %}
<li onclick="ContactActions_ajaxUnblock('{{ contact.id|echapJS }}'); Drawer.clear();">
<span class="primary icon green">
<i class="material-symbols">cancel</i>
@@ -144,7 +145,6 @@
</li>
{% endif %}
</ul>
{% endif %}
{% if resources %}
<ul class="list middle">
Binary file not shown.
+1
View File
@@ -87,4 +87,5 @@ async def main():
print(e)
await browser.close()
if __name__ == "__main__":
asyncio.run(main())
+68
View File
@@ -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())
+58
View File
@@ -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())