Skip to content

Commit 744cab1

Browse files
committed
feat: expire past members after 60 days
1 parent 8f58c47 commit 744cab1

File tree

6 files changed

+322
-52
lines changed

6 files changed

+322
-52
lines changed

src/chat.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use crate::config::Config;
2424
use crate::constants::{
2525
self, Blocked, Chattype, DC_CHAT_ID_ALLDONE_HINT, DC_CHAT_ID_ARCHIVED_LINK,
2626
DC_CHAT_ID_LAST_SPECIAL, DC_CHAT_ID_TRASH, DC_RESEND_USER_AVATAR_DAYS,
27+
TIMESTAMP_SENT_TOLERANCE,
2728
};
2829
use crate::contact::{self, Contact, ContactId, Origin};
2930
use crate::context::Context;
@@ -1962,6 +1963,34 @@ impl Chat {
19621963
}
19631964
}
19641965

1966+
/// Returns chat member list timestamp.
1967+
pub(crate) async fn member_list_timestamp(&self, context: &Context) -> Result<i64> {
1968+
if let Some(member_list_timestamp) = self.param.get_i64(Param::MemberListTimestamp) {
1969+
Ok(member_list_timestamp)
1970+
} else {
1971+
let creation_timestamp: i64 = context
1972+
.sql
1973+
.query_get_value("SELECT created_timestamp FROM chats WHERE id=?", (self.id,))
1974+
.await
1975+
.context("SQL error querying created_timestamp")?
1976+
.context("Chat not found")?;
1977+
Ok(creation_timestamp)
1978+
}
1979+
}
1980+
1981+
/// Returns true if member list is stale,
1982+
/// i.e. has not been updated for 60 days.
1983+
///
1984+
/// This is used primarily to detect the case
1985+
/// where the user just restored an old backup.
1986+
pub(crate) async fn member_list_is_stale(&self, context: &Context) -> Result<bool> {
1987+
let now = time();
1988+
let member_list_ts = self.member_list_timestamp(context).await?;
1989+
let is_stale = now.saturating_add(TIMESTAMP_SENT_TOLERANCE)
1990+
>= member_list_ts.saturating_add(60 * 24 * 3600);
1991+
Ok(is_stale)
1992+
}
1993+
19651994
/// Adds missing values to the msg object,
19661995
/// writes the record to the database and returns its msg_id.
19671996
///
@@ -3468,16 +3497,19 @@ pub async fn get_chat_contacts(context: &Context, chat_id: ChatId) -> Result<Vec
34683497

34693498
/// Returns a vector of contact IDs for given chat ID that are no longer part of the group.
34703499
pub async fn get_past_chat_contacts(context: &Context, chat_id: ChatId) -> Result<Vec<ContactId>> {
3500+
let now = time();
34713501
let list = context
34723502
.sql
34733503
.query_map(
34743504
"SELECT cc.contact_id
34753505
FROM chats_contacts cc
34763506
LEFT JOIN contacts c
34773507
ON c.id=cc.contact_id
3478-
WHERE cc.chat_id=? AND cc.add_timestamp < cc.remove_timestamp
3508+
WHERE cc.chat_id=?
3509+
AND cc.add_timestamp < cc.remove_timestamp
3510+
AND ? < cc.remove_timestamp
34793511
ORDER BY c.id=1, c.last_seen DESC, c.id DESC",
3480-
(chat_id,),
3512+
(chat_id, now.saturating_sub(60 * 24 * 3600)),
34813513
|row| row.get::<_, ContactId>(0),
34823514
|ids| ids.collect::<Result<Vec<_>, _>>().map_err(Into::into),
34833515
)

src/chat/chat_tests.rs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use super::*;
22
use crate::chatlist::get_archived_cnt;
33
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS};
44
use crate::headerdef::HeaderDef;
5+
use crate::imex::{has_backup, imex, ImexMode};
56
use crate::message::{delete_msgs, MessengerMessage};
67
use crate::receive_imf::receive_imf;
78
use crate::test_utils::{sync, TestContext, TestContextManager, TimeShiftFalsePositiveNote};
@@ -3365,3 +3366,160 @@ async fn unpromoted_group_no_tombstones() -> Result<()> {
33653366

33663367
Ok(())
33673368
}
3369+
3370+
/// Test that past members expire after 60 days.
3371+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3372+
async fn test_expire_past_members_after_60_days() -> Result<()> {
3373+
let mut tcm = TestContextManager::new();
3374+
3375+
let alice = &tcm.alice().await;
3376+
let fiona_addr = "fiona@example.net";
3377+
let alice_fiona_contact_id = Contact::create(alice, "Fiona", fiona_addr).await?;
3378+
3379+
let alice_chat_id =
3380+
create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?;
3381+
add_contact_to_chat(alice, alice_chat_id, alice_fiona_contact_id).await?;
3382+
alice
3383+
.send_text(alice_chat_id, "Hi! I created a group.")
3384+
.await;
3385+
remove_contact_from_chat(alice, alice_chat_id, alice_fiona_contact_id).await?;
3386+
assert_eq!(get_past_chat_contacts(alice, alice_chat_id).await?.len(), 1);
3387+
3388+
SystemTime::shift(Duration::from_secs(60 * 24 * 60 * 60 + 1));
3389+
assert_eq!(get_past_chat_contacts(alice, alice_chat_id).await?.len(), 0);
3390+
3391+
let bob = &tcm.bob().await;
3392+
let bob_addr = bob.get_config(Config::Addr).await?.unwrap();
3393+
let alice_bob_contact_id = Contact::create(alice, "Bob", &bob_addr).await?;
3394+
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
3395+
3396+
let add_message = alice.pop_sent_msg().await;
3397+
assert_eq!(add_message.payload.contains(fiona_addr), false);
3398+
let bob_add_message = bob.recv_msg(&add_message).await;
3399+
let bob_chat_id = bob_add_message.chat_id;
3400+
assert_eq!(get_chat_contacts(bob, bob_chat_id).await?.len(), 2);
3401+
assert_eq!(get_past_chat_contacts(bob, bob_chat_id).await?.len(), 0);
3402+
3403+
Ok(())
3404+
}
3405+
3406+
/// Test the case when Alice restores a backup older than 60 days
3407+
/// with outdated member list.
3408+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3409+
async fn test_restore_backup_after_60_days() -> Result<()> {
3410+
let backup_dir = tempfile::tempdir()?;
3411+
3412+
let mut tcm = TestContextManager::new();
3413+
3414+
let alice = &tcm.alice().await;
3415+
let bob = &tcm.bob().await;
3416+
let fiona = &tcm.fiona().await;
3417+
3418+
let bob_addr = bob.get_config(Config::Addr).await?.unwrap();
3419+
let alice_bob_contact_id = Contact::create(alice, "Bob", &bob_addr).await?;
3420+
3421+
let charlie_addr = "charlie@example.com";
3422+
let alice_charlie_contact_id = Contact::create(alice, "Charlie", charlie_addr).await?;
3423+
3424+
let alice_chat_id =
3425+
create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?;
3426+
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
3427+
add_contact_to_chat(alice, alice_chat_id, alice_charlie_contact_id).await?;
3428+
3429+
let alice_sent_promote = alice
3430+
.send_text(alice_chat_id, "Hi! I created a group.")
3431+
.await;
3432+
let bob_rcvd_promote = bob.recv_msg(&alice_sent_promote).await;
3433+
let bob_chat_id = bob_rcvd_promote.chat_id;
3434+
bob_chat_id.accept(bob).await?;
3435+
3436+
// Alice exports a backup.
3437+
imex(alice, ImexMode::ExportBackup, backup_dir.path(), None).await?;
3438+
3439+
remove_contact_from_chat(alice, alice_chat_id, alice_charlie_contact_id).await?;
3440+
assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 2);
3441+
assert_eq!(get_past_chat_contacts(alice, alice_chat_id).await?.len(), 1);
3442+
3443+
let remove_message = alice.pop_sent_msg().await;
3444+
assert_eq!(remove_message.payload.contains(charlie_addr), true);
3445+
bob.recv_msg(&remove_message).await;
3446+
3447+
// 60 days pass.
3448+
SystemTime::shift(Duration::from_secs(60 * 24 * 60 * 60 + 1));
3449+
3450+
assert_eq!(get_past_chat_contacts(alice, alice_chat_id).await?.len(), 0);
3451+
3452+
// Bob adds Fiona to the chat.
3453+
let fiona_addr = fiona.get_config(Config::Addr).await?.unwrap();
3454+
let bob_fiona_contact_id = Contact::create(bob, "Fiona", &fiona_addr).await?;
3455+
add_contact_to_chat(bob, bob_chat_id, bob_fiona_contact_id).await?;
3456+
3457+
let add_message = bob.pop_sent_msg().await;
3458+
alice.recv_msg(&add_message).await;
3459+
let fiona_add_message = fiona.recv_msg(&add_message).await;
3460+
let fiona_chat_id = fiona_add_message.chat_id;
3461+
fiona_chat_id.accept(fiona).await?;
3462+
3463+
// Fiona does not learn about Charlie,
3464+
// even from `Chat-Group-Past-Members`, because tombstone has expired.
3465+
assert_eq!(get_chat_contacts(fiona, fiona_chat_id).await?.len(), 3);
3466+
assert_eq!(get_past_chat_contacts(fiona, fiona_chat_id).await?.len(), 0);
3467+
3468+
// Fiona sends a message
3469+
// so chat is not stale for Bob again.
3470+
// Alice also receives the message,
3471+
// but will import a backup immediately afterwards,
3472+
// so it does not matter.
3473+
let fiona_sent_message = fiona.send_text(fiona_chat_id, "Hi!").await;
3474+
alice.recv_msg(&fiona_sent_message).await;
3475+
bob.recv_msg(&fiona_sent_message).await;
3476+
3477+
tcm.section("Alice imports old backup");
3478+
let alice = &tcm.unconfigured().await;
3479+
let backup = has_backup(alice, backup_dir.path()).await?;
3480+
imex(alice, ImexMode::ImportBackup, backup.as_ref(), None).await?;
3481+
3482+
// Alice thinks Charlie is in the chat, but does not know about Fiona.
3483+
assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 3);
3484+
assert_eq!(get_past_chat_contacts(alice, alice_chat_id).await?.len(), 0);
3485+
3486+
assert_eq!(get_chat_contacts(bob, bob_chat_id).await?.len(), 3);
3487+
assert_eq!(get_past_chat_contacts(bob, bob_chat_id).await?.len(), 0);
3488+
3489+
assert_eq!(get_chat_contacts(fiona, fiona_chat_id).await?.len(), 3);
3490+
assert_eq!(get_past_chat_contacts(fiona, fiona_chat_id).await?.len(), 0);
3491+
3492+
// Bob sends a text message to the chat, without a tombstone for Charlie.
3493+
// Alice learns about Fiona.
3494+
let bob_sent_text = bob.send_text(bob_chat_id, "Message.").await;
3495+
3496+
tcm.section("Alice sends a message to stale chat");
3497+
let alice_sent_text = alice
3498+
.send_text(alice_chat_id, "Hi! I just restored a backup.")
3499+
.await;
3500+
3501+
tcm.section("Alice sent a message to stale chat");
3502+
alice.recv_msg(&bob_sent_text).await;
3503+
fiona.recv_msg(&bob_sent_text).await;
3504+
3505+
bob.recv_msg(&alice_sent_text).await;
3506+
fiona.recv_msg(&alice_sent_text).await;
3507+
3508+
// Alice should have learned about Charlie not being part of the group
3509+
// by receiving Bob's message.
3510+
assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 3);
3511+
assert!(!is_contact_in_chat(alice, alice_chat_id, alice_charlie_contact_id).await?);
3512+
assert_eq!(get_past_chat_contacts(alice, alice_chat_id).await?.len(), 0);
3513+
3514+
// This should not add or restore Charlie for Bob and Fiona,
3515+
// Charlie is not part of the chat.
3516+
assert_eq!(get_chat_contacts(bob, bob_chat_id).await?.len(), 3);
3517+
assert_eq!(get_past_chat_contacts(bob, bob_chat_id).await?.len(), 0);
3518+
let bob_charlie_contact_id = Contact::create(bob, "Charlie", charlie_addr).await?;
3519+
assert!(!is_contact_in_chat(bob, bob_chat_id, bob_charlie_contact_id).await?);
3520+
3521+
assert_eq!(get_chat_contacts(fiona, fiona_chat_id).await?.len(), 3);
3522+
assert_eq!(get_past_chat_contacts(fiona, fiona_chat_id).await?.len(), 0);
3523+
3524+
Ok(())
3525+
}

src/mimefactory.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ fn new_address_with_name(name: &str, address: String) -> Address {
155155

156156
impl MimeFactory {
157157
pub async fn from_msg(context: &Context, msg: Message) -> Result<MimeFactory> {
158+
let now = time();
158159
let chat = Chat::load_from_db(context, msg.chat_id).await?;
159160
let attach_profile_data = Self::should_attach_profile_data(&msg);
160161
let undisclosed_recipients = chat.typ == Chattype::Broadcast;
@@ -240,7 +241,7 @@ impl MimeFactory {
240241
}
241242
}
242243
recipient_ids.insert(id);
243-
} else {
244+
} else if remove_timestamp.saturating_add(60 * 24 * 3600) > now {
244245
// Row is a tombstone,
245246
// member is not actually part of the group.
246247
if !recipients_contain_addr(&past_members, &addr) {
@@ -621,7 +622,13 @@ impl MimeFactory {
621622
);
622623
}
623624

624-
if !self.member_timestamps.is_empty() {
625+
let chat_memberlist_is_stale = if let Loaded::Message { chat, .. } = &self.loaded {
626+
chat.member_list_is_stale(context).await?
627+
} else {
628+
false
629+
};
630+
631+
if !self.member_timestamps.is_empty() && !chat_memberlist_is_stale {
625632
headers.push(
626633
Header::new_with_value(
627634
"Chat-Group-Member-Timestamps".into(),

src/param.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,6 @@ pub enum Param {
183183
GroupNameTimestamp = b'g',
184184

185185
/// For Chats: timestamp of member list update.
186-
///
187-
/// Deprecated 2025-01-07.
188186
MemberListTimestamp = b'k',
189187

190188
/// For Webxdc Message Instances: Current document name

src/receive_imf.rs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2328,7 +2328,40 @@ async fn apply_group_changes(
23282328
}
23292329

23302330
if is_from_in_chat {
2331-
if let Some(ref chat_group_member_timestamps) = mime_parser.chat_group_member_timestamps() {
2331+
if chat.member_list_is_stale(context).await? {
2332+
info!(context, "Member list is stale.");
2333+
let mut new_members: HashSet<ContactId> = HashSet::from_iter(to_ids.iter().copied());
2334+
new_members.insert(ContactId::SELF);
2335+
if !from_id.is_special() {
2336+
new_members.insert(from_id);
2337+
}
2338+
2339+
context
2340+
.sql
2341+
.transaction(|transaction| {
2342+
// Remove all contacts and tombstones.
2343+
transaction.execute(
2344+
"DELETE FROM chats_contacts
2345+
WHERE chat_id=?",
2346+
(chat_id,),
2347+
)?;
2348+
2349+
// Insert contacts with default timestamps of 0.
2350+
let mut statement = transaction.prepare(
2351+
"INSERT INTO chats_contacts (chat_id, contact_id)
2352+
VALUES (?, ?)",
2353+
)?;
2354+
for contact_id in &new_members {
2355+
statement.execute((chat_id, contact_id))?;
2356+
}
2357+
2358+
Ok(())
2359+
})
2360+
.await?;
2361+
send_event_chat_modified = true;
2362+
} else if let Some(ref chat_group_member_timestamps) =
2363+
mime_parser.chat_group_member_timestamps()
2364+
{
23322365
send_event_chat_modified |= update_chats_contacts_timestamps(
23332366
context,
23342367
chat_id,
@@ -2378,6 +2411,14 @@ async fn apply_group_changes(
23782411
send_event_chat_modified = true;
23792412
}
23802413
}
2414+
2415+
chat_id
2416+
.update_timestamp(
2417+
context,
2418+
Param::MemberListTimestamp,
2419+
mime_parser.timestamp_sent,
2420+
)
2421+
.await?;
23812422
}
23822423

23832424
let new_chat_contacts = HashSet::<ContactId>::from_iter(

0 commit comments

Comments
 (0)