diff --git a/frontend/widgets/Chat/chat.js b/frontend/widgets/Chat/chat.js index 7961075..cc9e70d 100644 --- a/frontend/widgets/Chat/chat.js +++ b/frontend/widgets/Chat/chat.js @@ -234,6 +234,8 @@ var Chat = { if (text == '') { Chat.disableSending(); + } else if (Chat.getOmemoState() == 'yes') { + Chat.sendEncryptedCorrection(jid, textarea.dataset.mid, text, isMuc); } else { let correct = Chat_ajaxHttpDaemonCorrect(jid, textarea.dataset.mid, text); correct.then(e => { @@ -301,6 +303,35 @@ var Chat = { } } }, + sendEncryptedCorrection: function (jid, mid, text, isMuc) { + let omemo = ChatOmemo.encrypt(jid, text, isMuc); + + if (!omemo) { + Chat.failedMessage(text); + return; + } + + omemo.then(omemoheader => { + let id = omemoheader.id = crypto.randomUUID(); + Chat.tempMessages[id] = text; + + Chat_ajaxHttpDaemonCorrect(jid, mid, null, omemoheader) + .then(e => Chat.sentMessage()) + .catch(e => Chat.failedMessage(text)); + }); + }, + // The server cannot read encrypted messages, the text comes from this browser + editEncrypted: function (id, mid) { + if (typeof ChatOmemoDB == 'undefined') return; + + ChatOmemoDB.getMessage(id).then(text => { + if (typeof text == 'string' && text != '' && !AESGCM_REGEX.test(text.trim())) { + Chat.setTextarea(text, mid); + } else { + Toast.send(Chat.action_impossible_encrypted_error); + } + }); + }, sentId: function (id) { if (Chat.tempMessages[id]) { ChatOmemoDB.putMessage(id, Chat.tempMessages[id]); @@ -405,11 +436,7 @@ var Chat = { return; } - if (!Chat.isEncrypted()) { - Chat_ajaxLast(textarea.dataset.jid, Boolean(textarea.dataset.muc)); - } else { - Toast.send(Chat.action_impossible_encrypted_error); - } + Chat_ajaxLast(textarea.dataset.jid, Boolean(textarea.dataset.muc)); }, resolveMessage: function (mid) { ChatActions_ajaxResolveMessage(mid); @@ -443,7 +470,7 @@ var Chat = { return; } - if (event.key == 'ArrowUp' && !Chat.isEncrypted()) { + if (event.key == 'ArrowUp') { Chat.editPrevious(); } else if (event.key == 'Escape' && (this.value == '' || Chat.edit == true)) { diff --git a/src/db.rs b/src/db.rs index cec61b6..ffe4a5a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -93,6 +93,7 @@ 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 INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1096,7 +1097,7 @@ pub async fn messages_search( let rows: Vec<(i64, String)> = sqlx::query_as(AssertSqlSafe(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 ORDER BY published DESC" + AND encrypted = 0 AND retracted = 0 AND body IS NOT NULL AND {NOT_REPLACED} ORDER BY published DESC" ))) .bind(user) .bind(jid) @@ -1131,13 +1132,20 @@ pub async fn messages_search( Ok(q.bind(limit).fetch_all(db).await?) } +/// Skips the messages that were corrected by their author (XEP-0308): only the last version is searched +/// (corrections all point to the first version, so a later one hides the earlier ones too) +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))"; + /// 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!( "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 ORDER BY published DESC LIMIT 5000" + AND type IN ({types_sql}) AND encrypted = 1 AND retracted = 0 AND {NOT_REPLACED} ORDER BY published DESC LIMIT 5000" ))) .bind(user) .bind(jid) @@ -1300,6 +1308,23 @@ mod tests { let index = encrypted_index(&db, me, bob, false).await.unwrap(); assert_eq!(index.iter().map(|e| e.0).collect::>(), vec![secret_mid]); + // Corrections: only the last version is searched + let typo = Message { id: "e1".into(), messageid: Some("e1".into()), body: Some("hẹn gặp lúc 9h".into()), published: days_ago(1), ..m.clone() }; + let fixed = Message { id: "e2".into(), replaceid: Some("e1".into()), body: Some("hẹn gặp lúc 10h".into()), published: days_ago(1), ..m.clone() }; + message_save(&db, &typo).await.unwrap(); + let fixed_mid = message_save(&db, &fixed).await.unwrap(); + assert!(messages_search(&db, me, bob, false, "9h", &[], 20).await.unwrap().is_empty()); + let found = messages_search(&db, me, bob, false, "hen gap", &[], 20).await.unwrap(); + assert_eq!(found.iter().map(|m| m.mid).collect::>(), vec![fixed_mid]); + let again = Message { id: "e4".into(), replaceid: Some("e1".into()), body: Some("hẹn gặp lúc 11h".into()), published: days_ago(1), ..m.clone() }; + let again_mid = message_save(&db, &again).await.unwrap(); + let found = messages_search(&db, me, bob, false, "hen gap", &[], 20).await.unwrap(); + assert_eq!(found.iter().map(|m| m.mid).collect::>(), vec![again_mid]); + // Someone else cannot hide a message with a fake correction + let fake = Message { id: "e3".into(), jidfrom: "eve@example.org".into(), replaceid: Some("s1".into()), published: days_ago(1), ..m.clone() }; + message_save(&db, &fake).await.unwrap(); + assert_eq!(messages_search(&db, me, bob, false, "xin chao", &[], 20).await.unwrap().len(), 1); + // Expiry: an old message goes, a recent one stays let old = Message { id: "old".into(), published: "2020-01-01 10:00:00".into(), ..m.clone() }; let new = Message { id: "new".into(), published: crate::utils::now_sql(), ..m.clone() }; diff --git a/src/widgets/chat.rs b/src/widgets/chat.rs index aa8fc6c..7dd7fcb 100644 --- a/src/widgets/chat.rs +++ b/src/widgets/chat.rs @@ -40,13 +40,13 @@ pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool { "ajaxGetRoom" => get_room(ctx, &p.str(0), p.b(1)).await, "ajaxHttpDaemonSendMessage" => send_from_params(ctx, p).await, "ajaxHttpDaemonCorrect" => { - // chat.js calls it with (to, mid, message) - let (to, mid, message) = if p.i(1).is_some() && p.v(3).is_none() { - (p.str(0), p.i(1).unwrap_or(0), p.str(2)) + // chat.js calls it with (to, mid, message, omemo) + let (to, mid, message, omemo) = if p.i(1).is_some() { + (p.str(0), p.i(1).unwrap_or(0), p.str(2), p.v(3).cloned()) } else { - (p.str(1), p.i(2).unwrap_or(0), p.str(3)) + (p.str(1), p.i(2).unwrap_or(0), p.str(3), p.v(4).cloned()) }; - correct(ctx, &to, mid, &message).await; + correct(ctx, &to, mid, &message, omemo).await; } "ajaxHttpDaemonSendReaction" => send_reaction(ctx, p.i(0).unwrap_or(0), &p.str(1)).await, "ajaxRefreshMessage" => refresh_message(ctx, p.i(0).unwrap_or(0)).await, @@ -1080,12 +1080,59 @@ fn omemo_element(o: &Value) -> minidom::Element { .build() } -async fn correct(ctx: &Ctx, to: &str, mid: i64, message: &str) { +async fn correct(ctx: &Ctx, to: &str, mid: i64, message: &str, omemo: Option) { let me = ctx.jid(); let Some(original) = db::message_by_mid(ctx.db(), &me, mid).await.ok().flatten() else { return }; + if !is_mine(ctx, &original) { + return; + } let muc = original.is_muc(); let to = if to.is_empty() || to == "false" { original.jid().to_string() } else { to.to_string() }; - send_message(ctx, &utils::generate_uuid(), &to, Some(message.to_string()), muc, None, None, Some(original), None).await; + // Encrypted corrections keep the id chosen by the browser, it stores the plaintext under it + let id = omemo + .as_ref() + .and_then(|o| o.get("id")) + .and_then(|i| i.as_str()) + .map(String::from) + .unwrap_or_else(utils::generate_uuid); + send_message(ctx, &id, &to, Some(message.to_string()), muc, None, None, Some(original), omemo).await; +} + +fn is_mine(ctx: &Ctx, m: &Message) -> bool { + if m.is_muc() { + m.resource.is_some() && rooms::my_nick(ctx, &m.jidfrom) == m.resource + } else { + m.jidfrom == ctx.jid() + } +} + +/// Corrections always point to the first version of a message (XEP-0308) +async fn original_mid(ctx: &Ctx, m: &Message) -> i64 { + if let Some(rid) = &m.replaceid { + if let Ok(Some(orig)) = db::message_resolve_reference(ctx.db(), &ctx.jid(), m.jid(), rid).await { + return orig.mid; + } + } + m.mid +} + +/// Put the latest version of one of my messages in the textarea to correct it +async fn start_edit(ctx: &Ctx, m: Message) { + if !is_mine(ctx, &m) || m.retracted || m.type_ != "chat" && m.type_ != "groupchat" { + return; + } + if db::message_file(ctx.db(), m.mid).await.ok().flatten().is_some() { + return; + } + let mid = original_mid(ctx, &m).await; + if m.encrypted { + // Only the browser can read it + if let Some(id) = m.messageid.clone().or(m.originid.clone()) { + rpc!(ctx, "Chat.editEncrypted", id, mid); + } + } else if let Some(body) = m.body.filter(|b| !b.is_empty()) { + rpc!(ctx, "Chat.setTextarea", body, mid); + } } async fn send_reaction(ctx: &Ctx, mid: i64, emoji: &str) { @@ -1135,36 +1182,16 @@ async fn send_reaction(ctx: &Ctx, mid: i64, emoji: &str) { async fn last(ctx: &Ctx, to: &str, muc: bool) { let me = ctx.jid(); let messages = db::messages_for(ctx.db(), &me, to, muc, None, None, 20).await.unwrap_or_default(); - let my_nick = if muc { rooms::my_nick(ctx, to) } else { None }; - let mine = messages.into_iter().find(|m| { - if muc { m.resource.is_some() && m.resource == my_nick } else { m.jidfrom == me } - }); - let Some(m) = mine else { return }; - if m.retracted || m.encrypted || m.type_ != "chat" && m.type_ != "groupchat" { - return; - } - if db::message_file(ctx.db(), m.mid).await.ok().flatten().is_some() { - return; - } - // Use the original message id so the correction replaces it - let mut mid = m.mid; - if let Some(rid) = &m.replaceid { - if let Ok(Some(orig)) = db::message_resolve_reference(ctx.db(), &me, to, rid).await { - mid = orig.mid; - } - } - if let Some(body) = m.body.filter(|b| !b.is_empty()) { - rpc!(ctx, "Chat.setTextarea", body, mid); + if let Some(m) = messages.into_iter().find(|m| is_mine(ctx, m)) { + start_edit(ctx, m).await; } } async fn edit(ctx: &Ctx, mid: i64) { let me = ctx.jid(); - let Some(m) = db::message_by_mid(ctx.db(), &me, mid).await.ok().flatten() else { return }; - if db::message_file(ctx.db(), mid).await.ok().flatten().is_some() { - return; + if let Some(m) = db::message_by_mid(ctx.db(), &me, mid).await.ok().flatten() { + start_edit(ctx, m).await; } - rpc!(ctx, "Chat.setTextarea", m.body.unwrap_or_default(), mid); } async fn reply(ctx: &Ctx, mid: i64) { diff --git a/src/widgets/chatactions.rs b/src/widgets/chatactions.rs index 93b903d..0c50387 100644 --- a/src/widgets/chatactions.rs +++ b/src/widgets/chatactions.rs @@ -94,7 +94,7 @@ async fn show_message_dialog(ctx: &Ctx, mid: i64) { let mine = if m.is_muc() { rooms::my_nick(ctx, &m.jidfrom) == m.resource && m.resource.is_some() } else { m.jidfrom == me }; let has_file = db::message_file(ctx.db(), mid).await.ok().flatten().is_some(); - let editable = mine && !m.encrypted && !has_file; + let editable = mine && !has_file && !m.retracted; let moderator = m.is_muc() && m.stanzaid.is_some() && ctx diff --git a/src/widgets_registry.rs b/src/widgets_registry.rs index fbdf425..bbe7ec8 100644 --- a/src/widgets_registry.rs +++ b/src/widgets_registry.rs @@ -131,7 +131,7 @@ pub static WIDGETS: &[WidgetDef] = &[ ("ajaxGetRoom", &["room", "noConnect"]), ("ajaxGetSpaceRoom", &["server", "node", "roomId"]), ("ajaxHttpDaemonSendMessage", &["id", "to", "message", "muc", "file", "replyToMid", "mucReceipts", "omemo"]), - ("ajaxHttpDaemonCorrect", &["id", "to", "mid", "message"]), + ("ajaxHttpDaemonCorrect", &["id", "to", "mid", "message", "omemo"]), ("ajaxHttpDaemonSendReaction", &["mid", "emoji"]), ("ajaxRefreshMessage", &["mid"]), ("ajaxLast", &["to", "muc"]), diff --git a/tests/e2e/browser_edit.py b/tests/e2e/browser_edit.py new file mode 100644 index 0000000..e80e321 --- /dev/null +++ b/tests/e2e/browser_edit.py @@ -0,0 +1,90 @@ +"""Message corrections (XEP-0308), in clear and in OMEMO chats.""" +import asyncio +from playwright.async_api import async_playwright +from browser import login, BASE, OUT + + +async def send(page, text): + await page.fill("#chat_textarea", text) + await page.press("#chat_textarea", "Enter") + await page.wait_for_timeout(2500) + + +async def edit_last(page, text): + """↑ in the empty textarea, then replace the text""" + await page.fill("#chat_textarea", "") + await page.press("#chat_textarea", "ArrowUp") + await page.wait_for_timeout(1500) + print(" textarea after ↑:", repr(await page.input_value("#chat_textarea"))) + await send(page, text) + + +async def bubbles(page, n=4): + return (await page.locator("#chat_widget .bubble p").all_inner_texts())[-n:] + + +async def open_chat(page, jid): + await page.goto(BASE + "/chat/" + jid) + await page.wait_for_selector("#chat_textarea", timeout=15000) + await page.wait_for_timeout(5000) + + +async def main(): + async with async_playwright() as p: + browser = await p.chromium.launch() + actx, alice, aerr = await login(browser, "alice@localhost", "alicepass", "editalice") + bctx, bob, berr = await login(browser, "bob@localhost", "bobpass", "editbob") + await alice.wait_for_timeout(4000) + await open_chat(alice, "bob@localhost") + await open_chat(bob, "alice@localhost") + + # Clear text + await alice.evaluate("ChatOmemo.disableContactState('bob@localhost')") + await alice.wait_for_timeout(2000) + await send(alice, "Hẹn gặp lúc 9h") + await edit_last(alice, "Hẹn gặp lúc 10h") + print("clear, alice:", await bubbles(alice, 2)) + print("clear, bob:", await bubbles(bob, 2)) + print("edited marks on bob:", await bob.locator("#chat_widget .edited").count()) + + # OMEMO + await alice.evaluate("ChatOmemo.enableContactState('bob@localhost', false)") + await alice.wait_for_timeout(5000) + print("alice omemo:", await alice.evaluate("Chat.getOmemoState()")) + await send(alice, "Bí mật: mật khẩu wifi là 1234") + await edit_last(alice, "Bí mật: mật khẩu wifi là 5678") + await alice.screenshot(path=f"{OUT}/edit_1_alice.png") + print("omemo, alice:", await bubbles(alice, 3)) + print("omemo, bob:", await bubbles(bob, 3)) + + # Second edit, from the message menu + mid = await alice.evaluate("[...document.querySelectorAll('#chat_widget .bubble [data-mid]')].pop().dataset.mid") + await alice.evaluate(f"Chat_ajaxEdit({mid})") + await alice.wait_for_timeout(1500) + print(" textarea from menu:", repr(await alice.input_value("#chat_textarea"))) + await send(alice, "Bí mật: mật khẩu wifi là 9999") + await bob.wait_for_timeout(2000) + await bob.screenshot(path=f"{OUT}/edit_2_bob.png") + print("omemo 2nd edit, bob:", await bubbles(bob, 3)) + + # After a reload only the last versions are shown + await open_chat(bob, "alice@localhost") + await open_chat(alice, "bob@localhost") + print("reload, bob:", await bubbles(bob, 3)) + print("reload, alice:", await bubbles(alice, 3)) + await bob.screenshot(path=f"{OUT}/edit_3_bob_reload.png") + + # Search finds the new text, not the old one + await bob.evaluate("ChatActions_ajaxShowSearchDialog('alice@localhost', false)") + await bob.wait_for_selector("form[name=search] input", timeout=10000) + await bob.wait_for_timeout(1000) + for q in ("wifi", "1234", "9h", "10h"): + await bob.fill("form[name=search] input", q) + await bob.wait_for_timeout(2500) + print(f"search {q!r}:", await bob.locator("#chat_search #message_preview li p").all_inner_texts()) + + for e in aerr + berr: + print(e) + await browser.close() + +asyncio.run(main())