diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 8f1fdf5f4..c87dc326f 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -37,6 +37,7 @@ use deltachat::ephemeral::Timer as EphemeralTimer; use deltachat::key::DcKey; use deltachat::message::MsgId; use deltachat::stock_str::StockMessage; +use deltachat::w30::StatusUpdateId; use deltachat::*; use deltachat::{accounts::Accounts, log::LogExt}; @@ -500,6 +501,7 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc: EventType::ImexFileWritten(_) => 0, EventType::SecurejoinInviterProgress { contact_id, .. } | EventType::SecurejoinJoinerProgress { contact_id, .. } => *contact_id as libc::c_int, + EventType::W30StatusUpdate { msg_id, .. } => msg_id.to_u32() as libc::c_int, } } @@ -541,6 +543,9 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc: EventType::SecurejoinInviterProgress { progress, .. } | EventType::SecurejoinJoinerProgress { progress, .. } => *progress as libc::c_int, EventType::ChatEphemeralTimerModified { timer, .. } => timer.to_u32() as libc::c_int, + EventType::W30StatusUpdate { + status_update_id, .. + } => status_update_id.to_u32() as libc::c_int, } } @@ -582,6 +587,7 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut | EventType::SecurejoinJoinerProgress { .. } | EventType::ConnectivityChanged | EventType::SelfavatarChanged + | EventType::W30StatusUpdate { .. } | EventType::ChatEphemeralTimerModified { .. } => ptr::null_mut(), EventType::ConfigureProgress { comment, .. } => { if let Some(comment) = comment { @@ -871,6 +877,52 @@ pub unsafe extern "C" fn dc_send_videochat_invitation( }) } +#[no_mangle] +pub unsafe extern "C" fn dc_send_w30_status_update( + context: *mut dc_context_t, + msg_id: u32, + descr: *const libc::c_char, + json: *const libc::c_char, +) -> libc::c_int { + if context.is_null() { + eprintln!("ignoring careless call to dc_send_w30_status_update()"); + return 0; + } + let ctx = &*context; + + block_on(ctx.send_w30_status_update( + MsgId::new(msg_id), + &to_string_lossy(descr), + &to_string_lossy(json), + )) + .log_err(ctx, "Failed to send w30 update") + .is_ok() as libc::c_int +} + +#[no_mangle] +pub unsafe extern "C" fn dc_get_w30_status_updates( + context: *mut dc_context_t, + msg_id: u32, + status_update_id: u32, +) -> *mut libc::c_char { + if context.is_null() { + eprintln!("ignoring careless call to dc_get_w30_status_updates()"); + return "".strdup(); + } + let ctx = &*context; + + block_on(ctx.get_w30_status_updates( + MsgId::new(msg_id), + if status_update_id == 0 { + None + } else { + Some(StatusUpdateId::new(status_update_id)) + }, + )) + .unwrap_or_else(|_| "".to_string()) + .strdup() +} + #[no_mangle] pub unsafe extern "C" fn dc_set_draft( context: *mut dc_context_t, diff --git a/examples/repl/cmdline.rs b/examples/repl/cmdline.rs index dcdcd9eef..015345f1a 100644 --- a/examples/repl/cmdline.rs +++ b/examples/repl/cmdline.rs @@ -387,6 +387,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu sendfile []\n\ sendhtml []\n\ sendsyncmsg\n\ + sendw30 \n\ videochat\n\ draft []\n\ devicemsg \n\ @@ -907,6 +908,16 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu Some(msg_id) => println!("sync message sent as {}.", msg_id), None => println!("sync message not needed."), }, + "sendw30" => { + ensure!( + !arg1.is_empty() && !arg2.is_empty(), + "Arguments expected" + ); + let msg_id = MsgId::new(arg1.parse()?); + context + .send_w30_status_update(msg_id, "this is a w30 status update", arg2) + .await?; + } "videochat" => { ensure!(sel_chat.is_some(), "No chat selected."); chat::send_videochat_invitation(&context, sel_chat.as_ref().unwrap().get_id()).await?; diff --git a/examples/repl/main.rs b/examples/repl/main.rs index 22eac2438..86721cb4b 100644 --- a/examples/repl/main.rs +++ b/examples/repl/main.rs @@ -169,7 +169,7 @@ const DB_COMMANDS: [&str; 10] = [ "housekeeping", ]; -const CHAT_COMMANDS: [&str; 35] = [ +const CHAT_COMMANDS: [&str; 36] = [ "listchats", "listarchived", "chat", @@ -191,6 +191,7 @@ const CHAT_COMMANDS: [&str; 35] = [ "sendfile", "sendhtml", "sendsyncmsg", + "sendw30", "videochat", "draft", "listmedia", diff --git a/src/chat.rs b/src/chat.rs index bb670c06f..c0dce7d98 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -37,6 +37,7 @@ use crate::mimeparser::SystemMessage; use crate::param::{Param, Params}; use crate::peerstate::{Peerstate, PeerstateVerifiedStatus}; use crate::stock_str; +use crate::w30::W30_SUFFIX; /// An chat item, such as a message or a marker. #[derive(Debug, Copy, Clone)] @@ -645,6 +646,9 @@ impl ChatId { .await? .context("no file stored in params")?; msg.param.set(Param::File, blob.as_name()); + if blob.suffix() == Some(W30_SUFFIX) { + msg.viewtype = Viewtype::W30; + } } } @@ -1796,6 +1800,7 @@ pub(crate) fn msgtype_has_file(msgtype: Viewtype) -> bool { Viewtype::Video => true, Viewtype::File => true, Viewtype::VideochatInvitation => false, + Viewtype::W30 => true, } } @@ -1836,6 +1841,11 @@ async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> { msg.param.set(Param::MimeType, mime); } } + + if msg.viewtype == Viewtype::W30 && blob.suffix() != Some(W30_SUFFIX) { + bail!("w30 message {} does not have suffix {}", blob, W30_SUFFIX); + } + info!( context, "Attaching \"{}\" for message type #{}.", diff --git a/src/constants.rs b/src/constants.rs index 124ca5d3c..015e92f87 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -295,6 +295,9 @@ pub enum Viewtype { /// Message is an invitation to a videochat. VideochatInvitation = 70, + + /// Message is an w30 object. + W30 = 80, } impl Default for Viewtype { @@ -339,6 +342,7 @@ mod tests { Viewtype::VideochatInvitation, Viewtype::from_i32(70).unwrap() ); + assert_eq!(Viewtype::W30, Viewtype::from_i32(80).unwrap()); } #[test] diff --git a/src/dc_receive_imf.rs b/src/dc_receive_imf.rs index 41d5f273a..4686e2690 100644 --- a/src/dc_receive_imf.rs +++ b/src/dc_receive_imf.rs @@ -258,6 +258,15 @@ pub(crate) async fn dc_receive_imf_inner( } } + if let Some(ref status_update) = mime_parser.w30_status_update { + if let Err(err) = context + .receive_status_update(insert_msg_id, status_update) + .await + { + warn!(context, "receive_imf cannot update status: {}", err); + } + } + if let Some(avatar_action) = &mime_parser.user_avatar { if from_id != 0 && context @@ -860,6 +869,15 @@ async fn add_parts( info!(context, "Existing non-decipherable message. (TRASH)"); } + if mime_parser.w30_status_update.is_some() && mime_parser.parts.len() == 1 { + if let Some(part) = mime_parser.parts.first() { + if part.typ == Viewtype::Text && part.msg.is_empty() { + chat_id = Some(DC_CHAT_ID_TRASH); + info!(context, "Message is a status update only (TRASH)"); + } + } + } + if is_mdn { chat_id = Some(DC_CHAT_ID_TRASH); } diff --git a/src/events.rs b/src/events.rs index c700b065a..714262670 100644 --- a/src/events.rs +++ b/src/events.rs @@ -9,6 +9,7 @@ use strum::EnumProperty; use crate::chat::ChatId; use crate::ephemeral::Timer as EphemeralTimer; use crate::message::MsgId; +use crate::w30::StatusUpdateId; #[derive(Debug)] pub struct Events { @@ -326,4 +327,10 @@ pub enum EventType { #[strum(props(id = "2110"))] SelfavatarChanged, + + #[strum(props(id = "2120"))] + W30StatusUpdate { + msg_id: MsgId, + status_update_id: StatusUpdateId, + }, } diff --git a/src/lib.rs b/src/lib.rs index 45f4a14ea..252aea7be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -87,6 +87,7 @@ pub mod stock_str; mod sync; mod token; mod update_helper; +pub mod w30; #[macro_use] mod dehtml; mod color; diff --git a/src/message.rs b/src/message.rs index 74f9ff3b2..06e02b673 100644 --- a/src/message.rs +++ b/src/message.rs @@ -121,6 +121,13 @@ WHERE id=?; .sql .execute("DELETE FROM msgs_mdns WHERE msg_id=?;", paramsv![self]) .await?; + context + .sql + .execute( + "DELETE FROM msgs_status_updates WHERE msg_id=?;", + paramsv![self], + ) + .await?; context .sql .execute("DELETE FROM msgs WHERE id=?;", paramsv![self]) @@ -1161,6 +1168,7 @@ pub fn guess_msgtype_from_suffix(path: &Path) -> Option<(Viewtype, &str)> { "ttf" => (Viewtype::File, "font/ttf"), "vcard" => (Viewtype::File, "text/vcard"), "vcf" => (Viewtype::File, "text/vcard"), + "w30" => (Viewtype::W30, "application/html+w30"), "wav" => (Viewtype::File, "audio/wav"), "weba" => (Viewtype::File, "audio/webm"), "webm" => (Viewtype::Video, "video/webm"), @@ -1691,6 +1699,14 @@ mod tests { guess_msgtype_from_suffix(Path::new("foo/bar-sth.mp3")), Some((Viewtype::Audio, "audio/mpeg")) ); + assert_eq!( + guess_msgtype_from_suffix(Path::new("foo/file.html")), + Some((Viewtype::File, "text/html")) + ); + assert_eq!( + guess_msgtype_from_suffix(Path::new("foo/file.w30")), + Some((Viewtype::W30, "application/html+w30")) + ); } #[async_std::test] diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 7a6d7af73..bce2fc559 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -641,6 +641,11 @@ impl<'a> MimeFactory<'a> { "Content-Type".to_string(), "multipart/report; report-type=multi-device-sync".to_string(), )) + } else if self.msg.param.get_cmd() == SystemMessage::W30StatusUpdate { + PartBuilder::new().header(( + "Content-Type".to_string(), + "multipart/report; report-type=status-update".to_string(), + )) } else { PartBuilder::new().message_type(MimeMultipartType::Mixed) }; @@ -915,7 +920,9 @@ impl<'a> MimeFactory<'a> { "ephemeral-timer-changed".to_string(), )); } - SystemMessage::LocationOnly | SystemMessage::MultiDeviceSync => { + SystemMessage::LocationOnly + | SystemMessage::MultiDeviceSync + | SystemMessage::W30StatusUpdate => { // This should prevent automatic replies, // such as non-delivery reports. // @@ -1152,6 +1159,14 @@ impl<'a> MimeFactory<'a> { let ids = self.msg.param.get(Param::Arg2).unwrap_or_default(); parts.push(context.build_sync_part(json.to_string()).await); self.sync_ids_to_delete = Some(ids.to_string()); + } else if command == SystemMessage::W30StatusUpdate { + let json = self.msg.param.get(Param::Arg).unwrap_or_default(); + parts.push(context.build_status_update_part(json).await); + } else if self.msg.viewtype == Viewtype::W30 { + let json = context.get_w30_status_updates(self.msg.id, None).await?; + if json != "[]" { + parts.push(context.build_status_update_part(&json).await); + } } if self.attach_selfavatar { diff --git a/src/mimeparser.rs b/src/mimeparser.rs index dcb54848b..61d20c959 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -66,6 +66,7 @@ pub struct MimeMessage { pub location_kml: Option, pub message_kml: Option, pub(crate) sync_items: Option, + pub(crate) w30_status_update: Option, pub(crate) user_avatar: Option, pub(crate) group_avatar: Option, pub(crate) mdn_reports: Vec, @@ -135,6 +136,10 @@ pub enum SystemMessage { /// Self-sent-message that contains only json used for multi-device-sync; /// if possible, we attach that to other messages as for locations. MultiDeviceSync = 20, + + // Sync message that contains a json payload + // sent to the other w30 instances + W30StatusUpdate = 30, } impl Default for SystemMessage { @@ -296,6 +301,7 @@ impl MimeMessage { location_kml: None, message_kml: None, sync_items: None, + w30_status_update: None, user_avatar: None, group_avatar: None, failure_report: None, @@ -538,7 +544,7 @@ impl MimeMessage { }; if let Some(ref subject) = self.get_subject() { - if !self.has_chat_version() { + if !self.has_chat_version() && self.w30_status_update.is_none() { part.msg = subject.to_string(); } } @@ -837,6 +843,12 @@ impl MimeMessage { .await?; } } + Some("status-update") => { + if let Some(second) = mail.subparts.get(1) { + self.add_single_part_if_known(context, second, is_related) + .await?; + } + } Some(_) => { if let Some(first) = mail.subparts.get(0) { any_part_added = self @@ -1006,8 +1018,13 @@ impl MimeMessage { if decoded_data.is_empty() { return; } - // treat location/message kml file attachments specially - if filename.ends_with(".kml") { + let msg_type = if context + .is_w30_file(filename, decoded_data) + .await + .unwrap_or(false) + { + Viewtype::W30 + } else if filename.ends_with(".kml") { // XXX what if somebody sends eg an "location-highlights.kml" // attachment unrelated to location streaming? if filename.starts_with("location") || filename.starts_with("message") { @@ -1023,6 +1040,7 @@ impl MimeMessage { } return; } + msg_type } else if filename == "multi-device-sync.json" { let serialized = String::from_utf8_lossy(decoded_data) .parse() @@ -1035,7 +1053,15 @@ impl MimeMessage { }) .ok(); return; - } + } else if filename == "status-update.json" { + let serialized = String::from_utf8_lossy(decoded_data) + .parse() + .unwrap_or_default(); + self.w30_status_update = Some(serialized); + return; + } else { + msg_type + }; /* we have a regular file attachment, write decoded data to new blob object */ diff --git a/src/summary.rs b/src/summary.rs index 6c590ff0e..b8129c024 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -137,7 +137,11 @@ impl Message { append_text = false; stock_str::videochat_invitation(context).await } - _ => { + Viewtype::W30 => { + append_text = true; + "W30".to_string() + } + Viewtype::Text | Viewtype::Unknown => { if self.param.get_cmd() != SystemMessage::LocationOnly { "".to_string() } else { diff --git a/src/w30.rs b/src/w30.rs new file mode 100644 index 000000000..471bda768 --- /dev/null +++ b/src/w30.rs @@ -0,0 +1,594 @@ +//! # Handle W30 messages. + +use crate::constants::Viewtype; +use crate::context::Context; +use crate::message::{Message, MessageState, MsgId}; +use crate::mimeparser::SystemMessage; +use crate::param::Param; +use crate::{chat, EventType}; +use anyhow::{bail, Result}; +use lettre_email::mime::{self}; +use lettre_email::PartBuilder; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::convert::TryFrom; + +pub const W30_SUFFIX: &str = "w30"; + +/// Status Update ID. +#[derive( + Debug, Copy, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, +)] +pub struct StatusUpdateId(u32); + +impl StatusUpdateId { + /// Create a new [MsgId]. + pub fn new(id: u32) -> StatusUpdateId { + StatusUpdateId(id) + } + + /// Gets StatusUpdateId as untyped integer. + /// Avoid using this outside ffi. + pub fn to_u32(self) -> u32 { + self.0 + } +} + +impl rusqlite::types::ToSql for StatusUpdateId { + fn to_sql(&self) -> rusqlite::Result { + let val = rusqlite::types::Value::Integer(self.0 as i64); + let out = rusqlite::types::ToSqlOutput::Owned(val); + Ok(out) + } +} + +#[derive(Debug, Deserialize)] +pub(crate) struct StatusUpdateItem { + payload: Value, +} + +impl Context { + pub(crate) async fn is_w30_file(&self, filename: &str, _decoded_data: &[u8]) -> Result { + if filename.ends_with(W30_SUFFIX) { + Ok(true) + } else { + Ok(false) + } + } + + async fn create_status_update_record( + &self, + instance_msg_id: MsgId, + payload: &str, + ) -> Result { + let payload = payload.trim(); + if payload.is_empty() { + bail!("create_status_update_record: empty payload"); + } + let _test: Value = serde_json::from_str(payload)?; // checks if input data are valid json + + let rowid = self + .sql + .insert( + "INSERT INTO msgs_status_updates (msg_id, payload) VALUES(?, ?);", + paramsv![instance_msg_id, payload], + ) + .await?; + Ok(StatusUpdateId(u32::try_from(rowid)?)) + } + + /// Sends a status update for an w30 instance. + /// + /// If the instance is a draft, + /// the status update is sent once the instance is actually sent. + /// + /// If an update is sent immediately, the message-id of the update-message is returned, + /// this update-message is visible in chats, however, the id may be useful. + pub async fn send_w30_status_update( + &self, + instance_msg_id: MsgId, + descr: &str, + payload: &str, + ) -> Result> { + let instance = Message::load_from_db(self, instance_msg_id).await?; + if instance.viewtype != Viewtype::W30 { + bail!("send_w30_status_update: is no w30 message"); + } + + let status_update_id = self + .create_status_update_record(instance_msg_id, payload) + .await?; + + match instance.state { + MessageState::Undefined | MessageState::OutPreparing | MessageState::OutDraft => { + // send update once the instance is actually send; + // on sending, the updates are retrieved using get_w30_status_updates() then. + Ok(None) + } + _ => { + // send update now + // (also send updates on MessagesState::Failed, maybe only one member cannot receive) + let mut status_update = Message { + chat_id: instance.chat_id, + viewtype: Viewtype::Text, + text: Some(descr.to_string()), + hidden: true, + ..Default::default() + }; + status_update.param.set_cmd(SystemMessage::W30StatusUpdate); + status_update.param.set( + Param::Arg, + self.get_w30_status_updates(instance_msg_id, Some(status_update_id)) + .await?, + ); + status_update.set_quote(self, &instance).await?; + let status_update_msg_id = + chat::send_msg(self, instance.chat_id, &mut status_update).await?; + Ok(Some(status_update_msg_id)) + } + } + } + + pub(crate) async fn build_status_update_part(&self, json: &str) -> PartBuilder { + PartBuilder::new() + .content_type(&"application/json".parse::().unwrap()) + .header(( + "Content-Disposition", + "attachment; filename=\"status-update.json\"", + )) + .body(json) + } + + /// Receives status updates from receive_imf to the database + /// and sends out an event. + /// + /// `msg_id` may be an instance (in case there are initial status updates) + /// or a reply to an instance (for all other updates). + /// + /// `json_array` is an array containing one or more payloads as created by send_w30_status_update(), + /// the array is parsed using serde, the single payloads are used as is. + pub(crate) async fn receive_status_update( + &self, + msg_id: MsgId, + json_array: &str, + ) -> Result<()> { + let msg = Message::load_from_db(self, msg_id).await?; + let instance = if msg.viewtype == Viewtype::W30 { + msg + } else if let Some(parent) = msg.parent(self).await? { + if parent.viewtype == Viewtype::W30 { + parent + } else { + bail!("receive_status_update: message is not the child of a W30 message.") + } + } else { + bail!("receive_status_update: status message has no parent.") + }; + + let payloads: Vec = serde_json::from_str(json_array)?; + for payload in payloads { + let status_update_id = self + .create_status_update_record(instance.id, &*serde_json::to_string(&payload)?) + .await?; + self.emit_event(EventType::W30StatusUpdate { + msg_id: instance.id, + status_update_id, + }); + } + + Ok(()) + } + + /// Returns status updates as an JSON-array. + /// + /// The updates may be filtered by a given status_update_id; + /// if no updates are available, an empty JSON-array is returned. + pub async fn get_w30_status_updates( + &self, + instance_msg_id: MsgId, + status_update_id: Option, + ) -> Result { + let json_array = self + .sql + .query_map( + "SELECT payload FROM msgs_status_updates WHERE msg_id=? AND (1=? OR id=?)", + paramsv![ + instance_msg_id, + if status_update_id.is_some() { 0 } else { 1 }, + status_update_id.unwrap_or(StatusUpdateId(0)) + ], + |row| row.get::<_, String>(0), + |rows| { + let mut json_array = String::default(); + for row in rows { + let json_entry = row?; + if !json_array.is_empty() { + json_array.push_str(",\n"); + } + json_array.push_str(&json_entry); + } + Ok(json_array) + }, + ) + .await?; + Ok(format!("[{}]", json_array)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::{create_group_chat, send_msg, send_text_msg, ChatId, ProtectionStatus}; + use crate::dc_receive_imf::dc_receive_imf; + use crate::test_utils::TestContext; + use crate::Event; + use async_std::fs::File; + use async_std::io::WriteExt; + use async_std::prelude::*; + use std::time::Duration; + + #[async_std::test] + async fn test_is_w30_file() -> Result<()> { + let t = TestContext::new().await; + assert!( + !t.is_w30_file( + "issue_523.txt", + include_bytes!("../test-data/message/issue_523.txt") + ) + .await? + ); + assert!( + t.is_w30_file( + "minimal.w30", + include_bytes!("../test-data/w30/minimal.w30") + ) + .await? + ); + Ok(()) + } + + async fn create_w30_instance(t: &TestContext) -> Result { + let file = t.get_blobdir().join("index.w30"); + File::create(&file) + .await? + .write_all("ola!".as_ref()) + .await?; + let mut instance = Message::new(Viewtype::File); + instance.set_file(file.to_str().unwrap(), None); + Ok(instance) + } + + async fn send_w30_instance(t: &TestContext, chat_id: ChatId) -> Result { + let mut instance = create_w30_instance(t).await?; + let instance_msg_id = send_msg(t, chat_id, &mut instance).await?; + Message::load_from_db(t, instance_msg_id).await + } + + #[async_std::test] + async fn test_send_w30_instance() -> Result<()> { + let t = TestContext::new_alice().await; + let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + + // send as .w30 file + let instance = send_w30_instance(&t, chat_id).await?; + assert_eq!(instance.viewtype, Viewtype::W30); + assert_eq!(instance.get_filename(), Some("index.w30".to_string())); + assert_eq!(instance.chat_id, chat_id); + + // sending using bad extension is not working, even when setting Viewtype to W30 + let file = t.get_blobdir().join("index.html"); + File::create(&file) + .await? + .write_all("ola!".as_ref()) + .await?; + let mut instance = Message::new(Viewtype::W30); + instance.set_file(file.to_str().unwrap(), None); + assert!(send_msg(&t, chat_id, &mut instance).await.is_err()); + + Ok(()) + } + + #[async_std::test] + async fn test_receive_w30_instance() -> Result<()> { + let t = TestContext::new_alice().await; + dc_receive_imf( + &t, + include_bytes!("../test-data/message/w30_good_extension.eml"), + "INBOX", + 1, + false, + ) + .await?; + let instance = t.get_last_msg().await; + assert_eq!(instance.viewtype, Viewtype::W30); + assert_eq!(instance.get_filename(), Some("index.w30".to_string())); + + dc_receive_imf( + &t, + include_bytes!("../test-data/message/w30_bad_extension.eml"), + "INBOX", + 2, + false, + ) + .await?; + let instance = t.get_last_msg().await; + assert_eq!(instance.viewtype, Viewtype::File); // we require the correct extension, only a mime type is not sufficient + assert_eq!(instance.get_filename(), Some("index.html".to_string())); + + Ok(()) + } + + #[async_std::test] + async fn test_delete_w30_instance() -> Result<()> { + let t = TestContext::new_alice().await; + let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + + let mut instance = create_w30_instance(&t).await?; + chat_id.set_draft(&t, Some(&mut instance)).await?; + let instance = chat_id.get_draft(&t).await?.unwrap(); + t.send_w30_status_update(instance.id, "descr", "42").await?; + assert_eq!( + t.get_w30_status_updates(instance.id, None).await?, + "[42]".to_string() + ); + + // set_draft(None) deletes the message without the need to simulate network + chat_id.set_draft(&t, None).await?; + assert_eq!( + t.get_w30_status_updates(instance.id, None).await?, + "[]".to_string() + ); + assert_eq!( + t.sql + .count("SELECT COUNT(*) FROM msgs_status_updates;", paramsv![],) + .await?, + 0 + ); + + Ok(()) + } + + #[async_std::test] + async fn test_create_status_update_record() -> Result<()> { + let t = TestContext::new_alice().await; + let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let instance = send_w30_instance(&t, chat_id).await?; + + assert_eq!(t.get_w30_status_updates(instance.id, None).await?, "[]"); + + let id = t + .create_status_update_record(instance.id, "\n\n{\"foo\":\"bar\"}\n") + .await?; + assert_eq!( + t.get_w30_status_updates(instance.id, Some(id)).await?, + r#"[{"foo":"bar"}]"# + ); + + assert!(t + .create_status_update_record(instance.id, "\n\n\n") + .await + .is_err()); + assert!(t + .create_status_update_record(instance.id, "bad json") + .await + .is_err()); + assert_eq!( + t.get_w30_status_updates(instance.id, Some(id)).await?, + r#"[{"foo":"bar"}]"# + ); + assert_eq!( + t.get_w30_status_updates(instance.id, None).await?, + r#"[{"foo":"bar"}]"# + ); + + let id = t + .create_status_update_record(instance.id, r#"{"foo2":"bar2"}"#) + .await?; + assert_eq!( + t.get_w30_status_updates(instance.id, Some(id)).await?, + r#"[{"foo2":"bar2"}]"# + ); + t.create_status_update_record(instance.id, "true").await?; + assert_eq!( + t.get_w30_status_updates(instance.id, None).await?, + r#"[{"foo":"bar"}, +{"foo2":"bar2"}, +true]"# + ); + + Ok(()) + } + + #[async_std::test] + async fn test_receive_status_update() -> Result<()> { + let t = TestContext::new_alice().await; + let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let instance = send_w30_instance(&t, chat_id).await?; + + t.receive_status_update(instance.id, r#"[{"foo":"bar"}]"#) + .await?; + assert_eq!( + t.get_w30_status_updates(instance.id, None).await?, + r#"[{"foo":"bar"}]"# + ); + + t.receive_status_update(instance.id, r#" [ 42 , 23 ] "#) + .await?; + assert_eq!( + t.get_w30_status_updates(instance.id, None).await?, + r#"[{"foo":"bar"}, +42, +23]"# + ); + + Ok(()) + } + + #[async_std::test] + async fn test_send_w30_status_update() -> Result<()> { + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + + // Alice sends an w30 instance and a status update + let alice_chat = alice.create_chat(&bob).await; + let alice_instance = send_w30_instance(&alice, alice_chat.id).await?; + let sent1 = &alice.pop_sent_msg().await; + assert_eq!(alice_instance.viewtype, Viewtype::W30); + assert!(!sent1.payload().contains("report-type=status-update")); + + let status_update_msg_id = alice + .send_w30_status_update(alice_instance.id, "descr text", r#"{"foo":"bar"}"#) + .await? + .unwrap(); + let sent2 = &alice.pop_sent_msg().await; + let alice_update = Message::load_from_db(&alice, status_update_msg_id).await?; + assert!(alice_update.hidden); + assert_eq!(alice_update.viewtype, Viewtype::Text); + assert_eq!(alice_update.get_filename(), None); + assert_eq!(alice_update.text, Some("descr text".to_string())); + assert_eq!(alice_update.chat_id, alice_instance.chat_id); + assert_eq!( + alice_update.parent(&alice).await?.unwrap().id, + alice_instance.id + ); + assert_eq!(alice_chat.id.get_msg_cnt(&alice).await?, 1); + assert!(sent2.payload().contains("report-type=status-update")); + assert!(sent2.payload().contains("descr text")); + assert_eq!( + alice + .get_w30_status_updates(alice_instance.id, None) + .await?, + r#"[{"foo":"bar"}]"# + ); + + alice + .send_w30_status_update(alice_instance.id, "bla text", r#"{"snipp":"snapp"}"#) + .await? + .unwrap(); + assert_eq!( + alice + .get_w30_status_updates(alice_instance.id, None) + .await?, + r#"[{"foo":"bar"}, +{"snipp":"snapp"}]"# + ); + + // Bob receives all messages + bob.recv_msg(sent1).await; + let bob_instance = bob.get_last_msg().await; + let bob_chat_id = bob_instance.chat_id; + assert_eq!(bob_instance.rfc724_mid, alice_instance.rfc724_mid); + assert_eq!(bob_instance.viewtype, Viewtype::W30); + assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, 1); + + let (event_tx, event_rx) = async_std::channel::bounded(100); + bob.add_event_sink(move |event: Event| { + let event_tx = event_tx.clone(); + async move { + if let EventType::W30StatusUpdate { .. } = event.typ { + event_tx.try_send(event).unwrap(); + } + } + }) + .await; + bob.recv_msg(sent2).await; + let event = event_rx + .recv() + .timeout(Duration::from_secs(10)) + .await + .expect("timeout waiting for W30StatusUpdate event") + .expect("missing W30StatusUpdate event"); + match event.typ { + EventType::W30StatusUpdate { + msg_id, + status_update_id, + } => { + assert_eq!( + bob.get_w30_status_updates(msg_id, Some(status_update_id)) + .await?, + r#"[{"foo":"bar"}]"# + ); + assert_eq!(msg_id, bob_instance.id); + } + _ => panic!("Wrong event type"), + } + assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, 1); + + assert_eq!( + bob.get_w30_status_updates(bob_instance.id, None).await?, + r#"[{"foo":"bar"}]"# + ); + + // Alice has a second device and also receives messages there + let alice2 = TestContext::new_alice().await; + alice2.recv_msg(sent1).await; + alice2.recv_msg(sent2).await; + let alice2_instance = alice2.get_last_msg().await; + let alice2_chat_id = alice2_instance.chat_id; + assert_eq!(alice2_instance.viewtype, Viewtype::W30); + assert_eq!(alice2_chat_id.get_msg_cnt(&alice2).await?, 1); + + Ok(()) + } + + #[async_std::test] + async fn test_draft_and_send_w30_status_update() -> Result<()> { + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + let alice_chat_id = alice.create_chat(&bob).await.id; + + // prepare w30 instance, + // status updates are not sent for drafts, therefore send_w30_status_update() returns Ok(None) + let mut alice_instance = create_w30_instance(&alice).await?; + alice_chat_id + .set_draft(&alice, Some(&mut alice_instance)) + .await?; + let mut alice_instance = alice_chat_id.get_draft(&alice).await?.unwrap(); + + let status_update_msg_id = alice + .send_w30_status_update(alice_instance.id, "descr", r#"{"foo":"bar"}"#) + .await?; + assert_eq!(status_update_msg_id, None); + let status_update_msg_id = alice + .send_w30_status_update(alice_instance.id, "descr", r#"42"#) + .await?; + assert_eq!(status_update_msg_id, None); + + // send w30 instance, + // the initial status updates are sent together in the same message + let alice_instance_id = send_msg(&alice, alice_chat_id, &mut alice_instance).await?; + let sent1 = alice.pop_sent_msg().await; + let alice_instance = Message::load_from_db(&alice, alice_instance_id).await?; + assert_eq!(alice_instance.viewtype, Viewtype::W30); + assert_eq!(alice_instance.get_filename(), Some("index.w30".to_string())); + assert_eq!(alice_instance.chat_id, alice_chat_id); + + // bob receives the instance together with the initial updates in a single message + bob.recv_msg(&sent1).await; + let bob_instance = bob.get_last_msg().await; + assert_eq!(bob_instance.viewtype, Viewtype::W30); + assert_eq!(bob_instance.get_filename(), Some("index.w30".to_string())); + assert!(sent1.payload().contains("Content-Type: application/json")); + assert!(sent1.payload().contains("status-update.json")); + assert!(sent1.payload().contains(r#"{"foo":"bar"}"#)); + assert_eq!( + bob.get_w30_status_updates(bob_instance.id, None).await?, + r#"[{"foo":"bar"}, +42]"# + ); + + Ok(()) + } + + #[async_std::test] + async fn test_send_w30_status_update_to_non_w30() -> Result<()> { + let t = TestContext::new_alice().await; + let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let msg_id = send_text_msg(&t, chat_id, "ho!".to_string()).await?; + assert!(t + .send_w30_status_update(msg_id, "descr", r#"{"foo":"bar"}"#) + .await + .is_err()); + Ok(()) + } +} diff --git a/test-data/message/w30_bad_extension.eml b/test-data/message/w30_bad_extension.eml new file mode 100644 index 000000000..6695aaf1d --- /dev/null +++ b/test-data/message/w30_bad_extension.eml @@ -0,0 +1,22 @@ +Subject: W30 object attached +Message-ID: 67890@example.org +Date: Fri, 03 Dec 2021 10:00:27 +0000 +To: alice@example.org +From: bob@example.org +Chat-Version: 1.0 +Content-Type: multipart/mixed; boundary="==BREAK==" + + +--==BREAK== +Content-Type: text/plain; charset=utf-8 + +w30 with correct extension; +this is not that important here. + +--==BREAK== +Content-Type: application/html+w30 +Content-Disposition: attachment; filename=index.html + +hey! + +--==BREAK==-- diff --git a/test-data/message/w30_good_extension.eml b/test-data/message/w30_good_extension.eml new file mode 100644 index 000000000..756bca416 --- /dev/null +++ b/test-data/message/w30_good_extension.eml @@ -0,0 +1,22 @@ +Subject: W30 object attached +Message-ID: 12345@example.org +Date: Fri, 03 Dec 2021 10:00:27 +0000 +To: alice@example.org +From: bob@example.org +Chat-Version: 1.0 +Content-Type: multipart/mixed; boundary="==BREAK==" + + +--==BREAK== +Content-Type: text/plain; charset=utf-8 + +w30 with good extension; +the mimetype is ignored then. + +--==BREAK== +Content-Type: text/html +Content-Disposition: attachment; filename=index.w30 + +hey! + +--==BREAK==-- diff --git a/test-data/w30/minimal.w30 b/test-data/w30/minimal.w30 new file mode 100644 index 000000000..63149c354 Binary files /dev/null and b/test-data/w30/minimal.w30 differ