twonly-app/rust/src/services/webxdc/mod.rs
2026-09-09 20:26:26 +02:00

1695 lines
65 KiB
Rust

/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
//! Webxdc app instances and their update log.
//!
//! An instance is one app placed into one chat. Its state is the ordered log of
//! updates in `webxdc_updates`, which lives outside `messages` on purpose: the
//! per-chat purge deletes message rows one by one, and a log with holes in it
//! rebuilds a state that is wrong rather than empty.
pub mod bundle;
pub mod store;
use crate::api::messages::outgoing::send_c2c_message_to_contact;
use crate::api::proto::client as proto;
use crate::context::Context;
use crate::database::app::tables::{MessageType, NewMessage};
use crate::error::{Result, TwonlyError};
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use sqlx::{Sqlite, Transaction};
use std::sync::Arc;
/// The spec's default ceiling for one serialised update.
pub const SEND_UPDATE_MAX_SIZE: usize = 128_000;
/// The spec's default pacing hint, advertised to apps as `sendUpdateInterval`.
pub const SEND_UPDATE_INTERVAL_MS: i64 = 10_000;
/// Bounds on how much an app may accumulate. An app is third-party code that
/// can call `sendUpdate` in a loop, so both the count and the total size need a
/// ceiling that does not depend on the app behaving.
const MAX_UPDATES_PER_INSTANCE: i64 = 100_000;
const MAX_PAYLOAD_BYTES_PER_INSTANCE: i64 = 32 * 1024 * 1024;
/// Sending burst allowance, counted over the trailing minute.
const MAX_UPDATES_PER_MINUTE: i64 = 12;
const SYNC_CHUNK_BYTES: usize = 48 * 1024;
const MAX_SYNC_CHUNKS: u32 = 700;
#[derive(sqlx::FromRow)]
struct SyncInstanceRow {
instance_id: String,
app_id: String,
version: i64,
summary: Option<String>,
document: Option<String>,
created_at: i64,
last_update_at: i64,
}
#[derive(sqlx::FromRow)]
struct SyncUpdateRow {
message_id: String,
sender_id: Option<i64>,
payload: String,
info: Option<String>,
href: Option<String>,
received_at: i64,
}
#[derive(sqlx::FromRow)]
struct PendingUpdateRow {
message_id: String,
sender_id: i64,
payload: String,
info: Option<String>,
href: Option<String>,
summary: Option<String>,
document: Option<String>,
received_at: i64,
}
/// Text an app can put in front of the user, outside its own sandbox. Bounded
/// so a chat row or a notification cannot be filled with app-controlled text.
const MAX_INFO_CHARS: usize = 200;
const MAX_SUMMARY_CHARS: usize = 64;
const MAX_DOCUMENT_CHARS: usize = 128;
pub struct WebxdcInstance {
pub instance_id: String,
pub group_id: String,
pub app_id: String,
pub version: i64,
pub bundle_sha256: Option<String>,
pub origin_token: String,
pub summary: Option<String>,
pub document: Option<String>,
}
pub struct WebxdcUpdate {
pub serial: i64,
pub payload: String,
pub info: Option<String>,
pub href: Option<String>,
/// `None` when this device sent the update.
pub sender_id: Option<i64>,
}
pub struct WebxdcService {
ctx: Arc<Context>,
}
impl WebxdcService {
pub fn new(ctx: &Arc<Context>) -> Self {
Self { ctx: ctx.clone() }
}
fn store(&self) -> store::WebxdcStore {
store::WebxdcStore::new(&self.ctx)
}
/// Requests existing one-time app state after joining a group. Spawned so
/// the group-create transaction can commit before the sender needs the app
/// database connection.
pub(crate) fn spawn_sync_request(ctx: &Arc<Context>, group_id: String, contact_id: i64) {
let ctx = ctx.clone();
tokio::spawn(async move {
let data = proto::AdditionalMessageData {
r#type: proto::additional_message_data::Type::WebxdcSyncRequest as i32,
webxdc_sync_request: Some(proto::WebxdcSyncRequest {}),
..Default::default()
};
let content = proto::EncryptedContent {
group_id: Some(group_id),
additional_data_message: Some(proto::encrypted_content::AdditionalDataMessage {
sender_message_id: uuid::Uuid::new_v4().to_string(),
timestamp: chrono::Utc::now().timestamp_millis(),
r#type: "webxdcSyncRequest".into(),
additional_message_data: Some(prost::Message::encode_to_vec(&data)),
hidden: true,
}),
..Default::default()
};
if let Err(error) = send_c2c_message_to_contact()
.ctx(&ctx)
.contact_id(contact_id)
.encrypted_content(prost::Message::encode_to_vec(&content))
.call()
.await
{
tracing::warn!(%error, "could not request one-time app state");
}
});
}
/// Sends every one-time app and its authoritative update log to one new
/// member. Transfers are hidden, encrypted contact-to-contact messages.
pub async fn send_one_time_apps_to_contact(
&self,
group_id: &str,
contact_id: i64,
) -> Result<()> {
let database = self.ctx.app_db.read().await.clone();
let local_user_id = self.ctx.user_id().await?;
let instances = sqlx::query_as::<_, SyncInstanceRow>(
r#"SELECT instance.instance_id, instance.app_id, instance.version,
instance.summary, instance.document, instance.created_at,
instance.last_update_at
FROM webxdc_instances AS instance
JOIN webxdc_apps AS app
ON app.app_id = instance.app_id AND app.version = instance.version
WHERE instance.group_id = ? AND app.one_time = 1
ORDER BY instance.created_at, instance.instance_id"#,
)
.bind(group_id)
.fetch_all(&database.pool)
.await?;
for instance in instances {
let updates = sqlx::query_as::<_, SyncUpdateRow>(
r#"SELECT message_id, sender_id, payload, info, href, received_at
FROM webxdc_updates WHERE instance_id = ? ORDER BY serial"#,
)
.bind(&instance.instance_id)
.fetch_all(&database.pool)
.await?;
let payload = proto::WebxdcSyncPayload {
instances: vec![proto::WebxdcSyncInstance {
instance_id: instance.instance_id,
app_id: instance.app_id,
version: instance.version,
summary: instance.summary,
document: instance.document,
created_at: instance.created_at,
last_update_at: instance.last_update_at,
updates: updates
.into_iter()
.map(|update| proto::WebxdcSyncUpdate {
message_id: update.message_id,
sender_id: update.sender_id.or(Some(local_user_id)),
payload: update.payload,
info: update.info,
href: update.href,
received_at: update.received_at,
})
.collect(),
}],
};
let encoded = prost::Message::encode_to_vec(&payload);
let chunk_count = encoded.len().div_ceil(SYNC_CHUNK_BYTES) as u32;
if chunk_count == 0 || chunk_count > MAX_SYNC_CHUNKS {
tracing::warn!(
instance_id = payload.instances[0].instance_id,
"one-time app is too large to sync"
);
continue;
}
let transfer_id = uuid::Uuid::new_v4().to_string();
let digest = Sha256::digest(&encoded).to_vec();
for (chunk_index, bytes) in encoded.chunks(SYNC_CHUNK_BYTES).enumerate() {
let data = proto::AdditionalMessageData {
r#type: proto::additional_message_data::Type::WebxdcSync as i32,
webxdc_sync: Some(proto::WebxdcSyncChunk {
transfer_id: transfer_id.clone(),
chunk_index: chunk_index as u32,
chunk_count,
payload_sha256: digest.clone(),
payload: bytes.to_vec(),
}),
..Default::default()
};
let content = proto::EncryptedContent {
group_id: Some(group_id.to_owned()),
additional_data_message: Some(
proto::encrypted_content::AdditionalDataMessage {
sender_message_id: uuid::Uuid::new_v4().to_string(),
timestamp: chrono::Utc::now().timestamp_millis(),
r#type: "webxdcSync".into(),
additional_message_data: Some(prost::Message::encode_to_vec(&data)),
hidden: true,
},
),
..Default::default()
};
send_c2c_message_to_contact()
.ctx(&self.ctx)
.contact_id(contact_id)
.encrypted_content(prost::Message::encode_to_vec(&content))
.call()
.await?;
}
}
Ok(())
}
pub(crate) async fn handle_sync_chunk(
transaction: &mut Transaction<'_, Sqlite>,
group_id: &str,
sender_id: i64,
chunk: &proto::WebxdcSyncChunk,
) -> Result<()> {
if chunk.transfer_id.is_empty()
|| chunk.chunk_count == 0
|| chunk.chunk_count > MAX_SYNC_CHUNKS
|| chunk.chunk_index >= chunk.chunk_count
|| chunk.payload.len() > SYNC_CHUNK_BYTES
|| chunk.payload_sha256.len() != 32
{
return Err(TwonlyError::Generic("invalid webxdc sync chunk".into()));
}
let sender_is_member: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = ? AND contact_id = ? AND (member_state IS NULL OR member_state != 'leftGroup'))",
)
.bind(group_id)
.bind(sender_id)
.fetch_one(&mut **transaction)
.await?;
if !sender_is_member {
return Err(TwonlyError::Generic(
"webxdc sync sender is not a group member".into(),
));
}
sqlx::query(
r#"INSERT INTO webxdc_sync_chunks
(transfer_id, group_id, sender_id, chunk_index, chunk_count,
payload_sha256, payload)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(transfer_id, sender_id, chunk_index) DO NOTHING"#,
)
.bind(&chunk.transfer_id)
.bind(group_id)
.bind(sender_id)
.bind(chunk.chunk_index as i64)
.bind(chunk.chunk_count as i64)
.bind(&chunk.payload_sha256)
.bind(&chunk.payload)
.execute(&mut **transaction)
.await?;
sqlx::query(
"DELETE FROM webxdc_sync_chunks WHERE created_at < strftime('%s','now') - 86400",
)
.execute(&mut **transaction)
.await?;
let rows: Vec<(i64, i64, Vec<u8>, Vec<u8>)> = sqlx::query_as(
r#"SELECT chunk_index, chunk_count, payload_sha256, payload
FROM webxdc_sync_chunks
WHERE transfer_id = ? AND sender_id = ?
ORDER BY chunk_index"#,
)
.bind(&chunk.transfer_id)
.bind(sender_id)
.fetch_all(&mut **transaction)
.await?;
if rows.len() != chunk.chunk_count as usize {
return Ok(());
}
if rows.iter().enumerate().any(|(index, row)| {
row.0 != index as i64
|| row.1 != chunk.chunk_count as i64
|| row.2 != chunk.payload_sha256
}) {
return Err(TwonlyError::Generic(
"inconsistent webxdc sync chunks".into(),
));
}
let encoded: Vec<u8> = rows.into_iter().flat_map(|row| row.3).collect();
if Sha256::digest(&encoded).as_slice() != chunk.payload_sha256.as_slice() {
return Err(TwonlyError::Generic("webxdc sync digest mismatch".into()));
}
let payload = <proto::WebxdcSyncPayload as prost::Message>::decode(encoded.as_slice())?;
Self::install_sync_payload(transaction, group_id, sender_id, payload).await?;
sqlx::query("DELETE FROM webxdc_sync_chunks WHERE transfer_id = ? AND sender_id = ?")
.bind(&chunk.transfer_id)
.bind(sender_id)
.execute(&mut **transaction)
.await?;
Ok(())
}
async fn install_sync_payload(
transaction: &mut Transaction<'_, Sqlite>,
group_id: &str,
sender_id: i64,
payload: proto::WebxdcSyncPayload,
) -> Result<()> {
if payload.instances.len() > 1 {
return Err(TwonlyError::Generic("invalid webxdc sync payload".into()));
}
for instance in payload.instances {
if instance.instance_id.is_empty()
|| instance.app_id.is_empty()
|| instance.updates.len() as i64 > MAX_UPDATES_PER_INSTANCE
|| instance
.updates
.iter()
.map(|update| update.payload.len() as i64)
.sum::<i64>()
> MAX_PAYLOAD_BYTES_PER_INSTANCE
{
return Err(TwonlyError::Generic("invalid webxdc sync payload".into()));
}
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM webxdc_instances WHERE instance_id = ?)",
)
.bind(&instance.instance_id)
.fetch_one(&mut **transaction)
.await?;
if exists {
Self::apply_pending_updates(
transaction,
&instance.instance_id,
&instance.app_id,
instance.version,
group_id,
)
.await?;
continue;
}
let app_data = proto::AdditionalMessageData {
r#type: proto::additional_message_data::Type::WebxdcApp as i32,
webxdc_app: Some(proto::WebxdcApp {
app_id: instance.app_id.clone(),
version: instance.version,
}),
..Default::default()
};
sqlx::query(
r#"INSERT INTO messages
(group_id, message_id, sender_id, type,
additional_message_data, created_at, ack_by_server)
VALUES (?, ?, ?, 'webxdcApp', ?, ?, strftime('%s','now'))
ON CONFLICT(message_id) DO NOTHING"#,
)
.bind(group_id)
.bind(&instance.instance_id)
.bind(sender_id)
.bind(prost::Message::encode_to_vec(&app_data))
.bind(instance.created_at)
.execute(&mut **transaction)
.await?;
Self::insert_instance(
transaction,
&instance.instance_id,
group_id,
&instance.app_id,
instance.version,
)
.await?;
sqlx::query("UPDATE webxdc_instances SET created_at = ? WHERE instance_id = ?")
.bind(instance.created_at)
.bind(&instance.instance_id)
.execute(&mut **transaction)
.await?;
for update in instance.updates {
if update.message_id.is_empty() || update.payload.len() > SEND_UPDATE_MAX_SIZE {
return Err(TwonlyError::Generic("invalid webxdc sync update".into()));
}
let info = sanitize(update.info.as_deref(), MAX_INFO_CHARS);
let href = update.href.as_deref().and_then(sanitize_href);
Self::append_update(
transaction,
&instance.instance_id,
&update.message_id,
update.sender_id,
&update.payload,
info.as_deref(),
href.as_deref(),
update.received_at,
)
.await?;
}
let summary = sanitize(instance.summary.as_deref(), MAX_SUMMARY_CHARS);
let document = sanitize(instance.document.as_deref(), MAX_DOCUMENT_CHARS);
Self::apply_instance_labels(
transaction,
&instance.instance_id,
summary.as_deref(),
document.as_deref(),
instance.last_update_at,
)
.await?;
Self::apply_pending_updates(
transaction,
&instance.instance_id,
&instance.app_id,
instance.version,
group_id,
)
.await?;
}
Ok(())
}
/// Places an app into a chat and tells the other members which app and
/// which version, so every participant runs the same code.
pub async fn create_instance(
&self,
group_id: String,
app_id: String,
version: i64,
) -> Result<String> {
// Resolved before anything is sent: pointing a chat at an app the store
// does not offer should fail here rather than on every recipient's
// device.
self.store().resolve_bundle_sha256(&app_id, version).await?;
if self
.existing_instance_if_limited(&group_id, &app_id, version)
.await?
.is_some()
{
return Err(TwonlyError::Generic(
"this app has already been added to the chat".into(),
));
}
let data = proto::AdditionalMessageData {
r#type: proto::additional_message_data::Type::WebxdcApp as i32,
webxdc_app: Some(proto::WebxdcApp {
app_id: app_id.clone(),
version,
}),
..Default::default()
};
let message_id = crate::services::messages::MessageService::new(&self.ctx)
.insert_and_send_additional_data(
group_id.clone(),
"webxdcApp".into(),
prost::Message::encode_to_vec(&data),
// The card is a real message: it shows in the chat and is worth
// a notification.
false,
)
.await?;
let database = self.ctx.app_db.read().await.clone();
let mut transaction = database.pool.begin().await?;
Self::insert_instance(&mut transaction, &message_id, &group_id, &app_id, version).await?;
transaction.commit().await?;
Ok(message_id)
}
async fn existing_instance_if_limited(
&self,
group_id: &str,
app_id: &str,
version: i64,
) -> Result<Option<String>> {
let database = self.ctx.app_db.read().await.clone();
let one_time: bool = sqlx::query_scalar(
"SELECT one_time FROM webxdc_apps WHERE app_id = ? AND version = ? AND published = 1",
)
.bind(app_id)
.bind(version)
.fetch_one(&database.pool)
.await?;
if !one_time {
return Ok(None);
}
Ok(sqlx::query_scalar(
"SELECT instance_id FROM webxdc_instances WHERE group_id = ? AND app_id = ? LIMIT 1",
)
.bind(group_id)
.bind(app_id)
.fetch_optional(&database.pool)
.await?)
}
/// Records the instance a peer placed into a chat. Nothing is downloaded
/// here: a message must not be able to make a device fetch a bundle, only
/// the user tapping Start may.
pub(crate) async fn handle_incoming_app(
transaction: &mut Transaction<'_, Sqlite>,
message_id: &str,
group_id: &str,
app: &proto::WebxdcApp,
) -> Result<()> {
Self::insert_instance(transaction, message_id, group_id, &app.app_id, app.version).await?;
Self::apply_pending_updates(transaction, message_id, &app.app_id, app.version, group_id)
.await
}
async fn insert_instance(
transaction: &mut Transaction<'_, Sqlite>,
instance_id: &str,
group_id: &str,
app_id: &str,
version: i64,
) -> Result<()> {
// Unguessable and per instance: this becomes the webview origin, and
// the origin is what keeps one instance's stored data out of reach of
// every other instance.
let origin_token = uuid::Uuid::new_v4().simple().to_string();
sqlx::query!(
r#"INSERT INTO webxdc_instances
(instance_id, group_id, app_id, version, origin_token)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(instance_id) DO NOTHING"#,
instance_id,
group_id,
app_id,
version,
origin_token,
)
.execute(&mut **transaction)
.await?;
Ok(())
}
/// Appends a peer's update to the log.
///
/// The instance is looked up by the id in the payload and checked against
/// the chat the message actually arrived in, so a member of one chat cannot
/// inject state into a game being played in another.
pub(crate) async fn handle_incoming_update(
transaction: &mut Transaction<'_, Sqlite>,
message_id: &str,
group_id: &str,
sender_id: i64,
update: &proto::WebxdcUpdate,
received_at: i64,
) -> Result<()> {
if update.instance_id.is_empty() || update.payload.len() > SEND_UPDATE_MAX_SIZE {
tracing::warn!("invalid webxdc update, dropping");
return Ok(());
}
let instance = sqlx::query!(
r#"SELECT group_id AS "group_id!: String", app_id AS "app_id!: String",
version AS "version!: i64"
FROM webxdc_instances WHERE instance_id = ?"#,
update.instance_id,
)
.fetch_optional(&mut **transaction)
.await?;
let Some(instance) = instance else {
// A newly joined member can receive a live update before the app
// snapshot. Keep it until the instance arrives; deletion removes
// these rows with the group.
sqlx::query(
"DELETE FROM webxdc_pending_updates WHERE received_at < strftime('%s','now') - 604800",
)
.execute(&mut **transaction)
.await?;
let pending: (i64, i64) = sqlx::query_as(
r#"SELECT COUNT(*), COALESCE(SUM(LENGTH(payload)), 0)
FROM webxdc_pending_updates WHERE instance_id = ? AND group_id = ?"#,
)
.bind(&update.instance_id)
.bind(group_id)
.fetch_one(&mut **transaction)
.await?;
if pending.0 >= MAX_UPDATES_PER_INSTANCE
|| pending.1 + update.payload.len() as i64 > MAX_PAYLOAD_BYTES_PER_INSTANCE
{
tracing::warn!("pending webxdc instance is over budget, dropping update");
return Ok(());
}
sqlx::query(
r#"INSERT INTO webxdc_pending_updates
(message_id, instance_id, group_id, sender_id, payload,
info, href, summary, document, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(message_id) DO NOTHING"#,
)
.bind(message_id)
.bind(&update.instance_id)
.bind(group_id)
.bind(sender_id)
.bind(&update.payload)
.bind(&update.info)
.bind(&update.href)
.bind(&update.summary)
.bind(&update.document)
.bind(received_at)
.execute(&mut **transaction)
.await?;
return Ok(());
};
if instance.group_id != group_id {
tracing::warn!("webxdc update arrived in the wrong chat, dropping");
return Ok(());
}
if !Self::within_instance_budget(transaction, &update.instance_id).await? {
tracing::warn!("webxdc instance is over budget, dropping update");
return Ok(());
}
let info = announcement(update);
let href = update.href.as_deref().and_then(sanitize_href);
let summary = sanitize(update.summary.as_deref(), MAX_SUMMARY_CHARS);
let document = sanitize(update.document.as_deref(), MAX_DOCUMENT_CHARS);
Self::append_update(
transaction,
&update.instance_id,
message_id,
Some(sender_id),
&update.payload,
info.as_deref(),
href.as_deref(),
received_at,
)
.await?;
Self::apply_instance_labels(
transaction,
&update.instance_id,
summary.as_deref(),
document.as_deref(),
received_at,
)
.await?;
if let Some(info) = info.as_deref() {
Self::insert_info_message(
transaction,
&update.instance_id,
&instance.app_id,
instance.version,
group_id,
message_id,
Some(sender_id),
info,
received_at,
)
.await?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn apply_pending_updates(
transaction: &mut Transaction<'_, Sqlite>,
instance_id: &str,
app_id: &str,
version: i64,
group_id: &str,
) -> Result<()> {
let rows = sqlx::query_as::<_, PendingUpdateRow>(
r#"SELECT message_id, sender_id, payload, info, href, summary, document, received_at
FROM webxdc_pending_updates
WHERE instance_id = ? AND group_id = ?
ORDER BY received_at, message_id"#,
)
.bind(instance_id)
.bind(group_id)
.fetch_all(&mut **transaction)
.await?;
for row in rows {
if row.message_id.is_empty() || row.payload.len() > SEND_UPDATE_MAX_SIZE {
continue;
}
if !Self::within_instance_budget(transaction, instance_id).await? {
tracing::warn!("webxdc instance is over budget, dropping pending updates");
break;
}
let info = sanitize(row.info.as_deref(), MAX_INFO_CHARS);
let href = row.href.as_deref().and_then(sanitize_href);
let summary = sanitize(row.summary.as_deref(), MAX_SUMMARY_CHARS);
let document = sanitize(row.document.as_deref(), MAX_DOCUMENT_CHARS);
Self::append_update(
transaction,
instance_id,
&row.message_id,
Some(row.sender_id),
&row.payload,
info.as_deref(),
href.as_deref(),
row.received_at,
)
.await?;
Self::apply_instance_labels(
transaction,
instance_id,
summary.as_deref(),
document.as_deref(),
row.received_at,
)
.await?;
if let Some(info) = info.as_deref() {
Self::insert_info_message(
transaction,
instance_id,
app_id,
version,
group_id,
&row.message_id,
Some(row.sender_id),
info,
row.received_at,
)
.await?;
}
}
sqlx::query("DELETE FROM webxdc_pending_updates WHERE instance_id = ? AND group_id = ?")
.bind(instance_id)
.bind(group_id)
.execute(&mut **transaction)
.await?;
Ok(())
}
/// Writes an update's `info` into the chat as a message of its own.
///
/// An app announces things a person is meant to read -- "a new game
/// started", "it is your turn". The update carrying it is state rather than
/// a message and stays hidden, so the announcement is materialised here
/// instead: an ordinary chat row that names the app it came from and points
/// back at its card.
///
/// Both devices build the row out of the update they already have, so
/// nothing extra is sent, and the id derived from the update's own message
/// id makes a redelivery a no-op rather than a second announcement.
#[allow(clippy::too_many_arguments)]
async fn insert_info_message(
transaction: &mut Transaction<'_, Sqlite>,
instance_id: &str,
app_id: &str,
version: i64,
group_id: &str,
update_message_id: &str,
sender_id: Option<i64>,
info: &str,
created_at: i64,
) -> Result<()> {
let origin = proto::AdditionalMessageData {
r#type: proto::additional_message_data::Type::WebxdcSent as i32,
webxdc_origin: Some(proto::WebxdcOrigin {
instance_id: instance_id.to_owned(),
app_id: app_id.to_owned(),
version,
}),
..Default::default()
};
let origin = prost::Message::encode_to_vec(&origin);
NewMessage::builder()
.group_id(group_id)
.message_id(&Self::info_message_id(update_message_id))
.message_type(MessageType::Text)
.content(info)
.maybe_sender_id(sender_id)
.additional_message_data(&origin)
.created_at(created_at)
// The announcement is built locally from an update that has already
// arrived, so there is nothing left to wait for.
.ack_by_server(created_at)
.build()
.insert(transaction)
.await
}
/// The id of the chat row an update's `info` becomes.
pub(crate) fn info_message_id(update_message_id: &str) -> String {
format!("{update_message_id}-info")
}
/// Assigns the next local serial and stores the entry.
///
/// Serials are per instance, gap free and never reused, which is exactly
/// what `setUpdateListener` promises the app. They are local: two devices
/// number the same update differently, and neither ever sends its numbering
/// to the other.
#[allow(clippy::too_many_arguments)]
async fn append_update(
transaction: &mut Transaction<'_, Sqlite>,
instance_id: &str,
message_id: &str,
sender_id: Option<i64>,
payload: &str,
info: Option<&str>,
href: Option<&str>,
received_at: i64,
) -> Result<()> {
let next_serial: i64 = sqlx::query_scalar!(
r#"SELECT COALESCE(MAX(serial), 0) + 1 AS "next!: i64"
FROM webxdc_updates WHERE instance_id = ?"#,
instance_id,
)
.fetch_one(&mut **transaction)
.await?;
// `message_id` is unique, so a redelivered message is a no-op rather
// than a duplicated move.
sqlx::query!(
r#"INSERT INTO webxdc_updates
(instance_id, serial, message_id, sender_id, payload, info, href, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(message_id) DO NOTHING"#,
instance_id,
next_serial,
message_id,
sender_id,
payload,
info,
href,
received_at,
)
.execute(&mut **transaction)
.await?;
Ok(())
}
async fn apply_instance_labels(
transaction: &mut Transaction<'_, Sqlite>,
instance_id: &str,
summary: Option<&str>,
document: Option<&str>,
received_at: i64,
) -> Result<()> {
sqlx::query!(
r#"UPDATE webxdc_instances
SET summary = COALESCE(?, summary),
document = COALESCE(?, document),
last_update_at = ?
WHERE instance_id = ?"#,
summary,
document,
received_at,
instance_id,
)
.execute(&mut **transaction)
.await?;
Ok(())
}
async fn within_instance_budget(
transaction: &mut Transaction<'_, Sqlite>,
instance_id: &str,
) -> Result<bool> {
let row = sqlx::query!(
r#"SELECT COUNT(*) AS "count!: i64",
COALESCE(SUM(LENGTH(payload)), 0) AS "bytes!: i64"
FROM webxdc_updates WHERE instance_id = ?"#,
instance_id,
)
.fetch_one(&mut **transaction)
.await?;
Ok(row.count < MAX_UPDATES_PER_INSTANCE && row.bytes < MAX_PAYLOAD_BYTES_PER_INSTANCE)
}
/// Sends an update the running app produced.
///
/// Everything crossing the bridge from the webview is checked here rather
/// than in `webxdc.js`, which the app can rewrite at will.
pub async fn send_update(
&self,
instance_id: String,
payload: String,
info: Option<String>,
href: Option<String>,
summary: Option<String>,
document: Option<String>,
notify: Option<String>,
) -> Result<()> {
if payload.len() > SEND_UPDATE_MAX_SIZE {
return Err(TwonlyError::Generic(format!(
"update of {} bytes exceeds sendUpdateMaxSize",
payload.len()
)));
}
let instance = self
.instance(&instance_id)
.await?
.ok_or_else(|| TwonlyError::Generic("unknown webxdc instance".into()))?;
let database = self.ctx.app_db.read().await.clone();
let now = chrono::Utc::now().timestamp();
let minute_ago = now - 60;
let recent: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!: i64" FROM webxdc_updates
WHERE instance_id = ? AND sender_id IS NULL AND received_at > ?"#,
instance_id,
minute_ago,
)
.fetch_one(&database.pool)
.await?;
if recent >= MAX_UPDATES_PER_MINUTE {
return Err(TwonlyError::Generic(
"this app is sending updates too quickly".into(),
));
}
let notify = if let Some(raw) = notify {
if raw.len() > SEND_UPDATE_MAX_SIZE {
return Err(TwonlyError::Generic(
"notification list is too large".into(),
));
}
let entries: std::collections::BTreeMap<String, String> = serde_json::from_str(&raw)
.map_err(|_| {
TwonlyError::Generic("notify must map member addresses to text".into())
})?;
let members = self.members(&instance_id).await?;
let members: serde_json::Value = serde_json::from_str(&members)?;
let mut cleaned = std::collections::BTreeMap::new();
for (address, text) in entries {
if !members["members"]
.as_array()
.unwrap()
.iter()
.any(|m| m["id"] == address)
{
return Err(TwonlyError::Generic(
"unknown notification recipient".into(),
));
}
if let Some(text) = sanitize(Some(&text), MAX_INFO_CHARS) {
cleaned.insert(address, text);
}
}
Some(serde_json::to_string(&cleaned)?)
} else {
None
};
let info = sanitize(info.as_deref(), MAX_INFO_CHARS);
let href = href.as_deref().and_then(sanitize_href);
let summary = sanitize(summary.as_deref(), MAX_SUMMARY_CHARS);
let document = sanitize(document.as_deref(), MAX_DOCUMENT_CHARS);
let data = proto::AdditionalMessageData {
r#type: proto::additional_message_data::Type::WebxdcUpdate as i32,
webxdc_update: Some(proto::WebxdcUpdate {
instance_id: instance_id.clone(),
payload: payload.clone(),
info: info.clone(),
href: href.clone(),
summary: summary.clone(),
document: document.clone(),
notify,
}),
..Default::default()
};
let message_id = crate::services::messages::MessageService::new(&self.ctx)
.insert_and_send_additional_data(
instance.group_id.clone(),
"webxdcUpdate".into(),
prost::Message::encode_to_vec(&data),
// App state, not a message. The log in `webxdc_updates` is the
// record; a chat row would only be noise, and a notification
// for every move would be worse.
true,
)
.await?;
let mut transaction = database.pool.begin().await?;
Self::append_update(
&mut transaction,
&instance_id,
&message_id,
None,
&payload,
info.as_deref(),
href.as_deref(),
now,
)
.await?;
Self::apply_instance_labels(
&mut transaction,
&instance_id,
summary.as_deref(),
document.as_deref(),
now,
)
.await?;
// The same row every other member of the chat builds when the update
// reaches them, so what was announced reads the same everywhere.
if let Some(info) = info.as_deref() {
Self::insert_info_message(
&mut transaction,
&instance_id,
&instance.app_id,
instance.version,
&instance.group_id,
&message_id,
None,
info,
now,
)
.await?;
}
transaction.commit().await?;
Ok(())
}
pub async fn instance(&self, instance_id: &str) -> Result<Option<WebxdcInstance>> {
let database = self.ctx.app_db.read().await.clone();
let row = sqlx::query!(
r#"SELECT instance_id AS "instance_id!: String", group_id AS "group_id!: String",
app_id AS "app_id!: String", version AS "version!: i64",
bundle_sha256 AS "bundle_sha256: String",
origin_token AS "origin_token!: String",
summary AS "summary: String", document AS "document: String"
FROM webxdc_instances WHERE instance_id = ?"#,
instance_id,
)
.fetch_optional(&database.pool)
.await?;
Ok(row.map(|row| WebxdcInstance {
instance_id: row.instance_id,
group_id: row.group_id,
app_id: row.app_id,
version: row.version,
bundle_sha256: row.bundle_sha256,
origin_token: row.origin_token,
summary: row.summary,
document: row.document,
}))
}
/// Everything the app has not seen yet, in serial order.
pub async fn updates_after(&self, instance_id: &str, serial: i64) -> Result<Vec<WebxdcUpdate>> {
let database = self.ctx.app_db.read().await.clone();
let rows = sqlx::query!(
r#"SELECT serial AS "serial!: i64", payload AS "payload!: String",
info AS "info: String", href AS "href: String",
sender_id AS "sender_id: i64"
FROM webxdc_updates
WHERE instance_id = ? AND serial > ?
ORDER BY serial ASC"#,
instance_id,
serial,
)
.fetch_all(&database.pool)
.await?;
Ok(rows
.into_iter()
.map(|row| WebxdcUpdate {
serial: row.serial,
payload: row.payload,
info: row.info,
href: row.href,
sender_id: row.sender_id,
})
.collect())
}
/// Starts with local code. Only a missing bundle needs the network.
/// Updates downloaded after a previous close are adopted here, before any
/// files are served; an in-flight background download never changes a run.
pub async fn prepare_bundle(&self, instance_id: &str) -> Result<std::path::PathBuf> {
let instance = self
.instance(instance_id)
.await?
.ok_or_else(|| TwonlyError::Generic("unknown webxdc instance".into()))?;
if let Some(path) = self.apply_cached_update(&instance, instance_id).await? {
return Ok(path);
}
self.pinned_bundle(instance_id).await
}
/// Checks and downloads after the app closes, without moving its pin.
/// The user may already have reopened it when the network finishes.
pub async fn cache_update(&self, instance_id: &str) -> Result<()> {
let Some(instance) = self.instance(instance_id).await? else {
return Ok(());
};
let store = self.store();
store.refresh_catalog().await?;
if let Some((version, sha256)) = store.newest_published(&instance.app_id).await? {
if version > instance.version {
store.ensure_bundle(&sha256).await?;
}
}
Ok(())
}
/// The bundle an instance is pinned to, on disk and verified.
///
/// No catalog lookup and no update check: this answers every file the
/// running app asks for, so all of them have to come out of the one bundle
/// the app started with. Only an instance that has never run here still
/// needs its hash resolved, which is what the fallback is for.
pub async fn pinned_bundle(&self, instance_id: &str) -> Result<std::path::PathBuf> {
let instance = self
.instance(instance_id)
.await?
.ok_or_else(|| TwonlyError::Generic("unknown webxdc instance".into()))?;
let store = self.store();
let sha256 = match instance.bundle_sha256 {
Some(sha256) => sha256,
None => {
let sha256 = store
.resolve_bundle_sha256(&instance.app_id, instance.version)
.await?;
Self::pin(&self.ctx, instance_id, instance.version, &sha256).await?;
sha256
}
};
store.ensure_bundle(&sha256).await
}
/// Adopts a newer version only if its complete, verified download is on disk.
/// Never fetch here: cached apps must open even while the server is down.
async fn apply_cached_update(
&self,
instance: &WebxdcInstance,
instance_id: &str,
) -> Result<Option<std::path::PathBuf>> {
let store = self.store();
let Some((version, sha256)) = store.newest_published(&instance.app_id).await? else {
return Ok(None);
};
let path = store.bundle_path(&sha256);
if version <= instance.version || !path.is_file() {
return Ok(None);
}
Self::pin(&self.ctx, instance_id, version, &sha256).await?;
tracing::info!(
"webxdc {} moved from version {} to {version}",
instance.app_id,
instance.version
);
if let Some(previous) = &instance.bundle_sha256 {
self.discard_unused_bundle(previous).await;
}
Ok(Some(path))
}
/// Deletes a bundle nothing points at any more, after an update moved the
/// last instance off it.
///
/// Bundles are content addressed and shared between instances, so the file
/// goes only once no instance is pinned to it. Best effort: a bundle left
/// behind costs disk space and nothing else.
async fn discard_unused_bundle(&self, sha256: &str) {
let database = self.ctx.app_db.read().await.clone();
let still_pinned = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!: i64" FROM webxdc_instances WHERE bundle_sha256 = ?"#,
sha256,
)
.fetch_one(&database.pool)
.await;
if !matches!(still_pinned, Ok(0)) {
return;
}
if let Err(error) = std::fs::remove_file(self.store().bundle_path(sha256)) {
tracing::info!("the superseded webxdc bundle stayed on disk: {error}");
}
}
/// Records the exact code an instance runs. Everything for the rest of this
/// run -- the files served, the bundle verified -- follows from it.
async fn pin(ctx: &Arc<Context>, instance_id: &str, version: i64, sha256: &str) -> Result<()> {
let database = ctx.app_db.read().await.clone();
sqlx::query!(
"UPDATE webxdc_instances SET version = ?, bundle_sha256 = ? WHERE instance_id = ?",
version,
sha256,
instance_id,
)
.execute(&database.pool)
.await?;
Ok(())
}
/// Current chat members, using the same instance-scoped IDs as selfAddr.
pub async fn members(&self, instance_id: &str) -> Result<String> {
let instance = self
.instance(instance_id)
.await?
.ok_or_else(|| TwonlyError::Generic("unknown webxdc instance".into()))?;
let user = crate::user_config::UserConfig::load_required_from(&self.ctx)?;
let database = self.ctx.app_db.read().await.clone();
let rows: Vec<(i64, String)> = sqlx::query_as(
"SELECT c.user_id, COALESCE(NULLIF(c.display_name, ''), c.username)
FROM group_members m JOIN contacts c ON c.user_id = m.contact_id
WHERE m.group_id = ? AND (m.member_state IS NULL OR m.member_state != 'leftGroup')
ORDER BY c.user_id",
)
.bind(&instance.group_id)
.fetch_all(&database.pool)
.await?;
let self_id = Self::address_for(instance_id, user.user_id);
let mut members =
vec![serde_json::json!({"id": self_id, "displayName": user.display_name})];
for (id, name) in rows {
if id != user.user_id {
members.push(serde_json::json!({"id": Self::address_for(instance_id, id), "displayName": name}));
}
}
Ok(serde_json::json!({"selfId": self_id, "members": members}).to_string())
}
/// The values `webxdc.js` is served with, as a JSON object.
///
/// Produced here rather than passed in, so what the app is told about its
/// user is decided on this side. `selfAddr` is a per-instance pseudonym;
/// `selfName` is the display name the chat already shows.
pub async fn init_script_values(&self, instance_id: &str) -> Result<String> {
let display_name = crate::user_config::UserConfig::load_from(&self.ctx)?
.map(|config| config.display_name)
.unwrap_or_default();
let user_id = crate::user_config::UserConfig::load_from(&self.ctx)?
.map(|config| config.user_id)
.unwrap_or_default();
Ok(serde_json::json!({
"selfAddr": Self::address_for(instance_id, user_id),
"selfName": display_name,
"sendUpdateInterval": SEND_UPDATE_INTERVAL_MS,
"sendUpdateMaxSize": SEND_UPDATE_MAX_SIZE,
})
.to_string())
}
/// The address the app sees for a participant.
///
/// Derived from the instance id, which every participant knows and nobody
/// outside the chat does, so the same peer looks identical on every device
/// while the same user in two different chats does not.
pub fn address_for(instance_id: &str, user_id: i64) -> String {
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(instance_id.as_bytes())
.expect("hmac accepts keys of any length");
mac.update(&user_id.to_be_bytes());
let digest = mac.finalize().into_bytes();
format!("{}@twonly", hex::encode(&digest[..8]))
}
/// Removes an instance and everything it accumulated, returning the origin
/// whose web storage the platform layer still has to clear. The update log
/// is only half the state: an app is free to keep everything in
/// `localStorage`, which no database delete can reach.
pub async fn delete_instance(&self, instance_id: &str) -> Result<Option<String>> {
let Some(instance) = self.instance(instance_id).await? else {
return Ok(None);
};
let database = self.ctx.app_db.read().await.clone();
let mut transaction = database.pool.begin().await?;
sqlx::query!(
"DELETE FROM webxdc_updates WHERE instance_id = ?",
instance_id
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"DELETE FROM webxdc_instances WHERE instance_id = ?",
instance_id
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(Some(instance.origin_token))
}
}
/// Strips what has no business in a chat row and bounds the length.
///
/// `info`, `summary` and `document` are written by the app and rendered outside
/// its sandbox, so they are plain text: no markup, no formatting, and no
/// direction overrides, which are otherwise enough to make a row read as
/// something it is not.
/// The announcement an update carries, bounded and stripped exactly as the
/// chat row built from it will be.
///
/// Every reader of an update's `info` goes through this, so what a device wakes
/// for, what it notifies for, and what it writes into the chat can never
/// disagree -- an app cannot buy a push with an `info` that turns out to be
/// nothing but spaces.
pub(crate) fn announcement(update: &proto::WebxdcUpdate) -> Option<String> {
sanitize(update.info.as_deref(), MAX_INFO_CHARS)
}
fn sanitize(text: Option<&str>, max_chars: usize) -> Option<String> {
let text = text?;
let cleaned: String = text
.chars()
.filter(|c| {
!c.is_control()
&& !matches!(
c,
'\u{200e}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
)
})
.take(max_chars)
.collect();
let cleaned = cleaned.trim().to_string();
(!cleaned.is_empty()).then_some(cleaned)
}
/// `href` decides which document the app opens when a chat row is tapped, so it
/// may only ever address the app's own files.
fn sanitize_href(href: &str) -> Option<String> {
let href = href.trim();
if href.is_empty() || href.len() > 512 {
return None;
}
// Anything that could name another origin or another scheme is refused
// outright rather than rewritten.
if href.starts_with("//") || href.contains(':') || href.contains('\\') || href.contains('\0') {
return None;
}
let path = href.split(['?', '#']).next().unwrap_or_default();
if path.split('/').any(|segment| segment == "..") {
return None;
}
Some(href.to_string())
}
#[cfg(test)]
mod tests {
use super::{proto, sanitize, sanitize_href, Context, Result, WebxdcService};
use sha2::{Digest, Sha256};
#[tokio::test]
async fn sync_chunks_install_one_time_app_only_when_complete() -> Result<()> {
let directory = tempfile::tempdir()?;
let ctx =
Context::init_for_testing(directory.path().join("db"), directory.path().join("data"))
.await?;
let database = ctx.app_db.read().await.clone();
for statement in [
"INSERT INTO contacts(user_id, username) VALUES (7, 'sender')",
"INSERT INTO groups(group_id, group_name) VALUES ('g', 'Chat')",
"INSERT INTO group_members(group_id, contact_id) VALUES ('g', 7)",
] {
sqlx::query(statement).execute(&database.pool).await?;
}
let payload = proto::WebxdcSyncPayload {
instances: vec![proto::WebxdcSyncInstance {
instance_id: "card".into(),
app_id: "expenses".into(),
version: 1,
summary: Some("Trip".into()),
document: Some("3 expenses".into()),
created_at: 100,
last_update_at: 200,
updates: vec![proto::WebxdcSyncUpdate {
message_id: "update".into(),
sender_id: Some(7),
payload: r#"{"expense":1}"#.into(),
info: Some("Dinner".into()),
href: None,
received_at: 150,
}],
}],
};
let encoded = prost::Message::encode_to_vec(&payload);
let split = encoded.len() / 2;
let digest = Sha256::digest(&encoded).to_vec();
let chunks = [
proto::WebxdcSyncChunk {
transfer_id: "transfer".into(),
chunk_index: 0,
chunk_count: 2,
payload_sha256: digest.clone(),
payload: encoded[..split].to_vec(),
},
proto::WebxdcSyncChunk {
transfer_id: "transfer".into(),
chunk_index: 1,
chunk_count: 2,
payload_sha256: digest,
payload: encoded[split..].to_vec(),
},
];
let mut transaction = database.pool.begin().await?;
WebxdcService::handle_incoming_update(
&mut transaction,
"live-update",
"g",
7,
&proto::WebxdcUpdate {
instance_id: "card".into(),
payload: r#"{"expense":2}"#.into(),
info: Some("Taxi".into()),
href: None,
summary: Some("Trip updated".into()),
document: Some("2 expenses".into()),
notify: None,
},
250,
)
.await?;
WebxdcService::handle_sync_chunk(&mut transaction, "g", 7, &chunks[1]).await?;
let before: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM webxdc_instances WHERE instance_id = 'card'")
.fetch_one(&mut *transaction)
.await?;
assert_eq!(before, 0);
WebxdcService::handle_sync_chunk(&mut transaction, "g", 7, &chunks[0]).await?;
transaction.commit().await?;
let instance = WebxdcService::new(&ctx).instance("card").await?.unwrap();
assert_eq!(instance.app_id, "expenses");
assert_eq!(instance.summary.as_deref(), Some("Trip updated"));
assert_eq!(instance.document.as_deref(), Some("2 expenses"));
let updates = WebxdcService::new(&ctx).updates_after("card", 0).await?;
assert_eq!(updates.len(), 2);
assert_eq!(updates[0].payload, r#"{"expense":1}"#);
assert_eq!(updates[1].payload, r#"{"expense":2}"#);
let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM webxdc_pending_updates")
.fetch_one(&database.pool)
.await?;
assert_eq!(pending, 0);
let announcements: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE message_id = 'live-update-info'")
.fetch_one(&database.pool)
.await?;
assert_eq!(announcements, 1);
Ok(())
}
#[tokio::test]
async fn one_time_app_refuses_another_instance_in_the_same_chat() -> Result<()> {
let directory = tempfile::tempdir()?;
let ctx =
Context::init_for_testing(directory.path().join("db"), directory.path().join("data"))
.await?;
let database = ctx.app_db.read().await.clone();
for statement in [
"INSERT INTO groups(group_id, group_name) VALUES ('g', 'Chat'), ('other', 'Other')",
"INSERT INTO messages(message_id, group_id, type) VALUES ('card', 'g', 'webxdcApp')",
"INSERT INTO webxdc_apps(app_id, version, name, bundle_sha256, bundle_bytes, cached_at, published, one_time) VALUES ('expenses', 1, 'Expenses', 'hash', 10, 0, 1, 1)",
"INSERT INTO webxdc_instances(instance_id, group_id, app_id, version, origin_token) VALUES ('card', 'g', 'expenses', 1, 'origin')",
] {
sqlx::query(statement).execute(&database.pool).await?;
}
let service = WebxdcService::new(&ctx);
assert_eq!(
service
.existing_instance_if_limited("g", "expenses", 1)
.await?
.as_deref(),
Some("card")
);
assert_eq!(
service
.existing_instance_if_limited("other", "expenses", 1)
.await?,
None
);
Ok(())
}
#[tokio::test]
async fn cached_launch_never_waits_for_updates_and_pins_only_between_runs() -> Result<()> {
let directory = tempfile::tempdir()?;
let ctx =
Context::init_for_testing(directory.path().join("db"), directory.path().join("data"))
.await?;
let database = ctx.app_db.read().await.clone();
for statement in [
"INSERT INTO groups(group_id, group_name) VALUES ('g', 'Chat')",
"INSERT INTO messages(message_id, group_id, type) VALUES ('card', 'g', 'webxdcApp')",
"INSERT INTO webxdc_instances(instance_id, group_id, app_id, version, origin_token, bundle_sha256) VALUES ('card', 'g', 'expenses', 1, 'origin', 'old-hash')",
"INSERT INTO webxdc_apps(app_id, version, name, bundle_sha256, bundle_bytes, cached_at, published) VALUES ('expenses', 2, 'Expenses', 'new-hash', 10, 0, 1)",
] {
sqlx::query(statement).execute(&database.pool).await?;
}
let service = WebxdcService::new(&ctx);
let old = service.store().bundle_path("old-hash");
let new = service.store().bundle_path("new-hash");
std::fs::create_dir_all(old.parent().unwrap())?;
std::fs::write(&old, b"previously downloaded code")?;
// A newer catalog row and an unfinished download cannot block launch.
std::fs::write(new.with_extension("partial"), b"unfinished")?;
let path = tokio::time::timeout(
std::time::Duration::from_millis(500),
service.prepare_bundle("card"),
)
.await
.expect("cached launch attempted a network request")?;
assert_eq!(path, old);
assert_eq!(service.instance("card").await?.unwrap().version, 1);
// Simulate a background download finishing after the user reopened.
std::fs::write(&new, b"new downloaded code")?;
assert_eq!(service.pinned_bundle("card").await?, old);
assert_eq!(service.instance("card").await?.unwrap().version, 1);
// Only the next launch adopts the already downloaded update.
assert_eq!(service.prepare_bundle("card").await?, new);
assert_eq!(service.instance("card").await?.unwrap().version, 2);
assert!(!old.exists());
Ok(())
}
#[tokio::test]
async fn member_api_is_scoped_and_excludes_departed_members() -> Result<()> {
let directory = tempfile::tempdir()?;
let ctx =
Context::init_for_testing(directory.path().join("db"), directory.path().join("data"))
.await?;
crate::user_config::UserConfig::save_json(
&ctx,
r#"{"userId":42,"username":"anna","displayName":"Anna"}"#,
)?;
let database = ctx.app_db.read().await.clone();
for statement in [
"INSERT INTO contacts(user_id, username, display_name) VALUES (7, 'ben', 'Ben'), (8, 'clara', 'Clara'), (9, 'dan', 'Dan')",
"INSERT INTO groups(group_id, group_name) VALUES ('g', 'Trip'), ('other', 'Other')",
"INSERT INTO group_members(group_id, contact_id, member_state) VALUES ('g', 7, NULL), ('g', 8, 'leftGroup'), ('other', 9, NULL)",
"INSERT INTO messages(message_id, group_id, type) VALUES ('card', 'g', 'webxdcApp')",
"INSERT INTO webxdc_instances(instance_id, group_id, app_id, version, origin_token) VALUES ('card', 'g', 'expenses', 1, 'origin')",
] {
sqlx::query(statement).execute(&database.pool).await?;
}
let service = WebxdcService::new(&ctx);
let members: serde_json::Value = serde_json::from_str(&service.members("card").await?)?;
assert_eq!(members["selfId"], WebxdcService::address_for("card", 42));
assert_eq!(members["members"].as_array().unwrap().len(), 2);
assert_eq!(members["members"][1]["displayName"], "Ben");
assert_eq!(
members["members"][1]["id"],
WebxdcService::address_for("card", 7)
);
assert!(service.members("missing").await.is_err());
assert!(service
.send_update(
"card".into(),
"{}".into(),
Some("Dinner".into()),
None,
None,
None,
Some(r#"{"unrelated-member":"Dinner"}"#.into())
)
.await
.is_err());
Ok(())
}
/// The announcement lands in the chat as an ordinary message, and lands
/// there once however often the update it came with is delivered.
#[tokio::test]
async fn an_announcement_becomes_a_chat_message_of_its_own() -> Result<()> {
let directory = tempfile::tempdir()?;
let context =
Context::init_for_testing(directory.path().join("db"), directory.path().join("data"))
.await?;
let database = context.app_db.read().await.clone();
let mut transaction = database.pool.begin().await?;
// A chat with an app card in it, which is what an instance is keyed by.
sqlx::query!("INSERT INTO groups(group_id, group_name) VALUES ('group-1', 'Chat')")
.execute(&mut *transaction)
.await?;
sqlx::query!(
r#"INSERT INTO messages(group_id, message_id, type, created_at)
VALUES ('group-1', 'card-1', 'webxdcApp', 0)"#
)
.execute(&mut *transaction)
.await?;
for _ in 0..2 {
WebxdcService::insert_info_message(
&mut transaction,
"card-1",
"mills",
3,
"group-1",
"update-1",
None,
"Alice is on turn",
1234,
)
.await?;
}
let rows = sqlx::query!(
r#"SELECT message_id AS "message_id!: String", content AS "content: String",
additional_message_data AS "data: Vec<u8>", created_at AS "created_at!: i64"
FROM messages WHERE type = 'text'"#
)
.fetch_all(&mut *transaction)
.await?;
assert_eq!(rows.len(), 1, "the same update announced twice");
assert_eq!(
rows[0].message_id,
WebxdcService::info_message_id("update-1")
);
assert_eq!(rows[0].content.as_deref(), Some("Alice is on turn"));
assert_eq!(rows[0].created_at, 1234);
// The card the row points back at.
let data = <proto::AdditionalMessageData as prost::Message>::decode(
rows[0].data.as_deref().expect("no origin recorded"),
)
.expect("the origin does not decode");
let origin = data.webxdc_origin.expect("no app named");
assert_eq!(origin.instance_id, "card-1");
assert_eq!(origin.app_id, "mills");
assert_eq!(origin.version, 3);
Ok(())
}
#[test]
fn sanitize_drops_control_and_direction_characters() {
assert_eq!(
sanitize(Some("your\u{202e}turn\n"), 200).as_deref(),
Some("yourturn")
);
assert_eq!(sanitize(Some(" "), 200), None);
assert_eq!(sanitize(Some("abcdef"), 3).as_deref(), Some("abc"));
}
#[test]
fn hrefs_may_only_address_the_app_itself() {
assert_eq!(
sanitize_href("board.html?id=3").as_deref(),
Some("board.html?id=3")
);
assert_eq!(
sanitize_href("/sub/page.html").as_deref(),
Some("/sub/page.html")
);
for href in [
"https://example.com",
"//example.com",
"javascript:alert(1)",
"data:text/html,x",
"../../etc/passwd",
"a\\b",
] {
assert!(sanitize_href(href).is_none(), "{href} was not refused");
}
}
#[test]
fn an_announcement_belongs_to_the_update_it_arrived_with() {
// Derived rather than random, so the same update redelivered writes the
// same row instead of announcing twice.
let id = WebxdcService::info_message_id("update-1");
assert_eq!(id, WebxdcService::info_message_id("update-1"));
assert_ne!(id, WebxdcService::info_message_id("update-2"));
assert_ne!(id, "update-1");
}
#[test]
fn addresses_are_stable_per_instance_and_differ_across_instances() {
let a = WebxdcService::address_for("instance-a", 42);
assert_eq!(a, WebxdcService::address_for("instance-a", 42));
assert_ne!(a, WebxdcService::address_for("instance-b", 42));
assert_ne!(a, WebxdcService::address_for("instance-a", 43));
}
}