Compare commits

...

3 Commits

Author SHA1 Message Date
iequidoo
12fdfc96a3 fix: Don't resend webxdc status updates in OutBroadcast-s (#7679)
We don't remember which status updates are "initial" (sent initially by the broadcast owner) and
which were sent by subscribers, so don't resend any of them to avoid accidental sharing of
confidential data.
2026-02-23 12:14:32 -03:00
iequidoo
de0b23d063 feat: Send webxdc status updates in InBroadcast-s (#7679)
They are already applied by the broadcast owner and other subscriber's devices, no changes are
needed on the receiver side.
2026-02-22 16:18:33 -03:00
iequidoo
b0ffe81415 feat: Don't send Chat-Group-Name* headers for InBroadcast-s
Broadcast subscribers can't change the chat name, so sending the "Chat-Group-Name{,-Timestamp}"
headers looks unnecessary. That could be useful for other subscriber's devices, but having only the
chat name isn't enough anyway, at least knowing the secret is necessary which is sent by the
broadcast owner.
2026-02-22 16:18:33 -03:00
6 changed files with 125 additions and 19 deletions

View File

@@ -2697,7 +2697,9 @@ async fn prepare_send_msg(
.unwrap_or_default(),
_ => false,
};
if let Some(reason) = chat.why_cant_send_ex(context, &skip_fn).await? {
if msg.param.get_cmd() == SystemMessage::WebxdcStatusUpdate {
// Already checked in `send_webxdc_status_update_struct()`.
} else if let Some(reason) = chat.why_cant_send_ex(context, &skip_fn).await? {
bail!("Cannot send to {chat_id}: {reason}");
}
@@ -4680,7 +4682,7 @@ pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
// note(treefit): only matters if it is the last message in chat (but probably to expensive to check, debounce also solves it)
chatlist_events::emit_chatlist_item_changed(context, msg.chat_id);
if msg.viewtype == Viewtype::Webxdc {
if msg.viewtype == Viewtype::Webxdc && msg.chat_typ != Chattype::OutBroadcast {
let conn_fn = |conn: &mut rusqlite::Connection| {
let range = conn.query_row(
"SELECT IFNULL(min(id), 1), IFNULL(max(id), 0) \

View File

@@ -107,12 +107,14 @@ pub const DC_CHAT_ID_LAST_SPECIAL: ChatId = ChatId::new(9);
IntoStaticStr,
Serialize,
Deserialize,
Default,
)]
#[repr(u32)]
pub enum Chattype {
/// A 1:1 chat, i.e. a normal chat with a single contact.
///
/// Created by [`ChatId::create_for_contact`].
#[default]
Single = 100,
/// Group chat.

View File

@@ -453,6 +453,7 @@ pub struct Message {
pub(crate) is_dc_message: MessengerMessage,
pub(crate) original_msg_id: MsgId,
pub(crate) mime_modified: bool,
pub(crate) chat_typ: Chattype,
pub(crate) chat_visibility: ChatVisibility,
pub(crate) chat_blocked: Blocked,
pub(crate) location_id: u32,
@@ -527,6 +528,7 @@ impl Message {
m.param AS param,
m.hidden AS hidden,
m.location_id AS location,
c.type AS chat_typ,
c.archived AS visibility,
c.blocked AS blocked
FROM msgs m
@@ -586,6 +588,10 @@ impl Message {
param: row.get::<_, String>("param")?.parse().unwrap_or_default(),
hidden: row.get("hidden")?,
location_id: row.get("location")?,
// This is safe: see `ChatId::delete_ex()`, `None` chat type can't happen.
chat_typ: row
.get::<_, Option<_>>("chat_typ")?
.unwrap_or(Chattype::Single),
chat_visibility: row.get::<_, Option<_>>("visibility")?.unwrap_or_default(),
chat_blocked: row
.get::<_, Option<Blocked>>("blocked")?

View File

@@ -1589,10 +1589,7 @@ impl MimeFactory {
}
}
if chat.typ == Chattype::Group
|| chat.typ == Chattype::OutBroadcast
|| chat.typ == Chattype::InBroadcast
{
if chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast {
headers.push((
"Chat-Group-Name",
mail_builder::headers::text::Text::new(chat.name.to_string()).into(),
@@ -1603,7 +1600,11 @@ impl MimeFactory {
mail_builder::headers::text::Text::new(ts.to_string()).into(),
));
}
}
if chat.typ == Chattype::Group
|| chat.typ == Chattype::OutBroadcast
|| chat.typ == Chattype::InBroadcast
{
match command {
SystemMessage::MemberRemovedFromGroup => {
let email_to_remove = msg.param.get(Param::Arg).unwrap_or_default();
@@ -2036,14 +2037,15 @@ impl MimeFactory {
HeaderDef::IrohGossipTopic.get_headername(),
mail_builder::headers::raw::Raw::new(topic).into(),
));
if let (Some(json), _) = context
.render_webxdc_status_update_object(
msg.id,
StatusUpdateSerial::MIN,
StatusUpdateSerial::MAX,
None,
)
.await?
if msg.chat_typ != Chattype::OutBroadcast
&& let (Some(json), _) = context
.render_webxdc_status_update_object(
msg.id,
StatusUpdateSerial::MIN,
StatusUpdateSerial::MAX,
None,
)
.await?
{
parts.push(context.build_status_update_part(&json));
}

View File

@@ -34,7 +34,7 @@ use serde_json::Value;
use sha2::{Digest, Sha256};
use tokio::{fs::File, io::BufReader};
use crate::chat::{self, Chat};
use crate::chat::{self, CantSendReason, Chat};
use crate::constants::Chattype;
use crate::contact::ContactId;
use crate::context::Context;
@@ -536,9 +536,14 @@ impl Context {
let chat = Chat::load_from_db(self, chat_id)
.await
.with_context(|| format!("Failed to load chat {chat_id} from the database"))?;
if let Some(reason) = chat.why_cant_send(self).await.with_context(|| {
format!("Failed to check if webxdc update can be sent to chat {chat_id}")
})? {
let skip_fn = |reason: &CantSendReason| *reason == CantSendReason::InBroadcast;
if let Some(reason) = chat
.why_cant_send_ex(self, &skip_fn)
.await
.with_context(|| {
format!("Failed to check if webxdc update can be sent to chat {chat_id}")
})?
{
bail!("Cannot send to {chat_id}: {reason}.");
}

View File

@@ -11,7 +11,9 @@ use crate::chat::{
use crate::chatlist::Chatlist;
use crate::config::Config;
use crate::ephemeral;
use crate::imex::{BackupProvider, get_backup};
use crate::receive_imf::receive_imf;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{E2EE_INFO_MSGS, TestContext, TestContextManager};
use crate::tools::{self, SystemTime};
use crate::{message, sql};
@@ -1588,6 +1590,93 @@ async fn test_webxdc_no_internet_access() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_in_broadcast_send_status_update() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = create_broadcast(alice, "bc".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
let mut alice_instance = create_webxdc_instance(
alice,
"minimal.xdc",
include_bytes!("../../test-data/webxdc/minimal.xdc"),
)?;
alice_chat_id
.set_draft(alice, Some(&mut alice_instance))
.await?;
alice_instance = alice_chat_id.get_draft(alice).await?.unwrap();
alice
.send_webxdc_status_update(alice_instance.id, r#"{"payload":41}"#)
.await?;
send_msg(alice, alice_chat_id, &mut alice_instance).await?;
let sent_msg = alice.pop_sent_msg().await;
let bob_instance = bob.recv_msg(&sent_msg).await;
assert_eq!(bob_instance.chat_id, bob_chat_id);
let provider = BackupProvider::prepare(bob).await?;
let bob2 = &tcm.unconfigured().await;
get_backup(bob2, provider.qr()).await?;
bob.send_webxdc_status_update(bob_instance.id, r#"{"payload":42}"#)
.await?;
bob.flush_status_updates().await?;
// Don't wait to make sure that status updates are sent immediately, otherwise the check for
// Alice below isn't reliable.
let sent_msg = bob.pop_sent_msg_opt(Duration::ZERO).await.unwrap();
alice.recv_msg_trash(&sent_msg).await;
assert_eq!(
alice
.get_webxdc_status_updates(alice_instance.id, StatusUpdateSerial(0))
.await?,
r#"[{"payload":41,"serial":1,"max_serial":2},
{"payload":42,"serial":2,"max_serial":2}]"#
);
bob2.recv_msg_trash(&sent_msg).await;
assert_eq!(
bob2.get_webxdc_status_updates(bob_instance.id, StatusUpdateSerial(0))
.await?,
r#"[{"payload":41,"serial":1,"max_serial":2},
{"payload":42,"serial":2,"max_serial":2}]"#
);
// Non-subscriber's status updates are rejected.
let alice_bob_id = alice.add_or_lookup_contact_id(bob).await;
remove_contact_from_chat(alice, alice_chat_id, alice_bob_id).await?;
alice.pop_sent_msg().await;
let status =
helper_send_receive_status_update(bob, alice, &bob_instance, &alice_instance).await?;
assert_eq!(
status,
r#"[{"payload":41,"serial":1,"max_serial":2},
{"payload":42,"serial":2,"max_serial":2}]"#
);
// Subscribers' status updates are confidential and shalln't be re-sent. So initial status
// updates aren't re-sent too.
let fiona = &tcm.fiona().await;
let fiona_chat_id = tcm.exec_securejoin_qr(fiona, alice, &qr).await;
resend_msgs(alice, &[alice_instance.id]).await?;
let sent1 = alice.pop_sent_msg().await;
alice.flush_status_updates().await?;
assert!(alice.pop_sent_msg_opt(Duration::ZERO).await.is_none());
let fiona_instance = fiona.recv_msg(&sent1).await;
assert_eq!(fiona_instance.chat_id, fiona_chat_id);
assert_eq!(fiona_instance.chat_typ, Chattype::InBroadcast);
assert_eq!(
fiona
.get_webxdc_status_updates(fiona_instance.id, StatusUpdateSerial(0))
.await?,
r#"[]"#
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_webxdc_chatlist_summary() -> Result<()> {
let t = TestContext::new_alice().await;