[Add] Client settings

This commit is contained in:
Alex Emmet 2026-09-01 22:17:57 +02:00
commit 430c12e139
No known key found for this signature in database
22 changed files with 2779 additions and 127 deletions

View file

@ -5,12 +5,21 @@ use iota_storage::util::communities_util::CommunitiesUtil;
use iota_storage::util::e2ee_storage::{self, ChatSecretQuery};
use iota_storage::util::settings;
use iota_storage::util::synced_settings::{self, SettingScope, SyncedSetting};
use iota_storage::util::user_blobs::{self, UserBlob};
use iota_storage::util::{blocked_users, message_storage_policy, receipt_policy};
use mtp::codec::{
CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, VerifiedRelayContent,
};
use crate::relay::VerifiedRelayContext;
use iota_storage::storage_error::StorageError;
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT_NOTIFICATION_ID: AtomicU32 = AtomicU32::new(1);
fn next_notification_id() -> u32 {
NEXT_NOTIFICATION_ID.fetch_add(1, Ordering::Relaxed).max(1)
}
#[derive(Debug)]
pub struct MessageMutation {
@ -25,6 +34,18 @@ pub struct SettingMutation {
pub changed: Option<CommunicationValue>,
}
#[derive(Debug)]
pub struct BlobMutation {
pub response: CommunicationValue,
pub changed: Option<CommunicationValue>,
}
#[derive(Debug)]
pub struct PolicyMutation {
pub response: CommunicationValue,
pub changed: Option<CommunicationValue>,
}
struct SettingLocator {
scope: SettingScope,
scope_key: String,
@ -174,6 +195,15 @@ pub fn apply_verified_relay_content(
.map(MessageState::from_str)
.filter(|state| matches!(state, MessageState::Received | MessageState::Read))
.ok_or_else(|| "Relay MessageState has an invalid state".to_string())?;
if sent_by_self {
return chat_files::change_message_state_by_relay_id(
storage_owner,
recipient_id,
relay_message_id,
state,
)
.map_err(|error| error.to_string());
}
chat_files::record_message_receipt(
storage_owner,
recipient_id,
@ -323,6 +353,32 @@ pub fn apply_verified_relay_content(
}
}
/* Local receipt disclosure is checked before the relay enters durable state. */
pub fn validate_outgoing_receipt_policy(
sender_id: i64,
context: &VerifiedRelayContext,
content: &VerifiedRelayContent,
) -> Result<(), String> {
if content.message_type != "MessageState" {
return Ok(());
}
let state = relay_string(&content.content, DataType::MessageState, &context.type_map)
.map(MessageState::from_str)
.filter(|state| matches!(state, MessageState::Received | MessageState::Read))
.ok_or_else(|| "local MessageState has an invalid state".to_string())?;
let policy = receipt_policy::get(sender_id).map_err(|error| error.to_string())?;
let allowed = match state {
MessageState::Read => policy.send_read_receipts,
MessageState::Received => policy.send_received_receipts,
MessageState::Sent | MessageState::Sending => false,
};
if allowed {
Ok(())
} else {
Err("local receipt disclosure is disabled by policy".into())
}
}
pub fn handle_message_edit(cv: &CommunicationValue) -> CommunicationValue {
let mutation = match message_mutation(cv) {
Ok(mutation) => mutation,
@ -777,9 +833,11 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue {
Err(_) => return sync_error(cv),
};
let known_session = sync::has_session(user_id, session_id).unwrap_or(false);
let acknowledged_version = sync::acknowledged_version(user_id, session_id).unwrap_or(None);
let full = !cache_valid
|| reported_version == 0
|| !known_session
|| acknowledged_version.is_some_and(|version| reported_version < version)
|| reported_version > head
|| schema != CACHE_SCHEMA_VERSION;
let (contacts, messages, settings, deleted_messages, deleted_contacts, deleted_settings, mode) =
@ -845,11 +903,59 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue {
.iter()
.map(|message| stored_message_value(message, user_id, message.external_user))
.collect();
if iota_storage::util::client_message_delivery::record_sync_delivery(
user_id,
session_id,
head,
messages.iter().map(|message| message.id),
)
.is_err()
{
return sync_error(cv);
}
let (blobs, deleted_blobs) = if mode == "delta" {
match sync::delta(user_id, reported_version, head) {
Ok(delta) => {
let blobs = match user_blobs::list_by_ids(user_id, &delta.blob_upserts) {
Ok(blobs) => blobs,
Err(_) => return sync_error(cv),
};
let deleted =
match user_blobs::list_deleted_by_ids(user_id, &delta.deleted_blob_ids) {
Ok(blobs) => blobs,
Err(_) => return sync_error(cv),
};
(blobs, deleted)
}
Err(_) => return sync_error(cv),
}
} else {
match user_blobs::list(user_id) {
Ok(blobs) => (blobs, Vec::new()),
Err(_) => return sync_error(cv),
}
};
let blocked_users = match blocked_users::list(user_id) {
Ok(users) => users,
Err(_) => return sync_error(cv),
};
let receipt_policy = match receipt_policy::get(user_id) {
Ok(policy) => policy,
Err(_) => return sync_error(cv),
};
let message_storage_policy = match message_storage_policy::get(user_id) {
Ok(policy) => policy,
Err(_) => return sync_error(cv),
};
let contact_ids = match current_contact_ids(user_id) {
Ok(contact_ids) => contact_ids,
Err(_) => return error_response(cv, CommunicationType::ErrorInternal),
};
CommunicationValue::new(CommunicationType::ClientStateSync)
let retention_duration = match message_storage_policy.retention {
message_storage_policy::MessageRetention::Forever => None,
message_storage_policy::MessageRetention::Duration { duration_ms } => Some(duration_ms),
};
let mut response = CommunicationValue::new(CommunicationType::ClientStateSync)
.with_request_id(cv)
.with_receiver(sender_wire_id(user_id))
.add_typed_default(
@ -879,6 +985,45 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue {
DataType::Settings,
DataValue::Array(settings.iter().map(synced_setting_value).collect()),
)
.add_typed_default(
DataType::Blobs,
DataValue::Array(blobs.iter().map(blob_value).collect()),
)
.add_typed_default(
DataType::DeletedBlobIds,
DataValue::Array(
deleted_blobs
.iter()
.map(|blob| DataValue::Str(blob.blob_id.clone()))
.collect(),
),
)
.add_typed_default(
DataType::BlockedUserIds,
DataValue::Array(
blocked_users
.into_iter()
.map(|id| DataValue::SignedNumber(id.into()))
.collect(),
),
)
.add_typed_default(
DataType::SendReadReceipts,
DataValue::Bool(receipt_policy.send_read_receipts),
)
.add_typed_default(
DataType::SendReceivedReceipts,
DataValue::Bool(receipt_policy.send_received_receipts),
)
.add_typed_default(
DataType::MessageHistoryMode,
DataValue::Str(match message_storage_policy.history_mode {
message_storage_policy::MessageHistoryMode::Retain => "retain".into(),
message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery => {
"delete_after_client_delivery".into()
}
}),
)
.add_typed_default(
DataType::Communities,
DataValue::Array(community_values(user_id)),
@ -911,11 +1056,18 @@ pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue {
),
)
.add_typed_default(DataType::UserIds, contact_ids)
.add_typed_default(DataType::Calls, DataValue::Array(Vec::new()))
.add_typed_default(DataType::Calls, DataValue::Array(Vec::new()));
if let Some(duration_ms) = retention_duration {
response = response.add_typed_default(
DataType::MessageRetentionDuration,
DataValue::SignedNumber(duration_ms.into()),
);
}
response
}
pub fn handle_client_state_ack(cv: &CommunicationValue) -> CommunicationValue {
use iota_storage::util::sync::{self, CACHE_SCHEMA_VERSION};
use iota_storage::util::sync::CACHE_SCHEMA_VERSION;
let user_id = match required_sender_id(cv) {
Ok(id) if id > 0 => id,
_ => return sync_error(cv),
@ -928,7 +1080,14 @@ pub fn handle_client_state_ack(cv: &CommunicationValue) -> CommunicationValue {
Some(version) if version >= 0 => version,
_ => return sync_error(cv),
};
if sync::acknowledge(user_id, session_id, version, CACHE_SCHEMA_VERSION).is_err() {
if iota_storage::util::client_message_delivery::acknowledge_client_state(
user_id,
session_id,
version,
CACHE_SCHEMA_VERSION,
)
.is_err()
{
return sync_error(cv);
}
success_response(cv)
@ -1483,6 +1642,66 @@ fn setting_response(
)
}
fn blob_value(blob: &UserBlob) -> DataValue {
typed_container(vec![
(DataType::BlobId, DataValue::Str(blob.blob_id.clone())),
(DataType::Blob, DataValue::Bytes(blob.blob.clone())),
(
DataType::VersionNumber,
DataValue::SignedNumber(blob.revision.into()),
),
(
DataType::UpdatedAt,
DataValue::SignedNumber(blob.updated_at.into()),
),
])
}
fn blob_response(
cv: &CommunicationValue,
ty: CommunicationType,
blob: &UserBlob,
) -> CommunicationValue {
CommunicationValue::new(ty)
.with_request_id(cv)
.with_receiver(sender_wire_id(blob.user_id))
.add_typed_default(DataType::BlobId, DataValue::Str(blob.blob_id.clone()))
.add_typed_default(DataType::Blob, DataValue::Bytes(blob.blob.clone()))
.add_typed_default(
DataType::VersionNumber,
DataValue::SignedNumber(blob.revision.into()),
)
.add_typed_default(
DataType::UpdatedAt,
DataValue::SignedNumber(blob.updated_at.into()),
)
}
fn blob_changed(user_id: i64, blob_id: String, revision: i64, deleted: bool) -> CommunicationValue {
CommunicationValue::new(CommunicationType::UserBlobChanged)
.with_id(next_notification_id())
.with_receiver(sender_wire_id(user_id))
.add_typed_default(DataType::BlobId, DataValue::Str(blob_id))
.add_typed_default(
DataType::VersionNumber,
DataValue::SignedNumber(revision.into()),
)
.add_typed_default(DataType::Deleted, DataValue::Bool(deleted))
}
fn blob_request(cv: &CommunicationValue) -> Result<(i64, String), CommunicationValue> {
let user_id = required_sender_id(cv)?;
if user_id <= 0 {
return Err(error_response(cv, CommunicationType::ErrorInvalidData));
}
let id = cv
.get_data(DataType::BlobId)
.and_then(DataValue::as_str)
.filter(|id| !id.is_empty())
.ok_or_else(|| error_response(cv, CommunicationType::ErrorInvalidData))?;
Ok((user_id, id.to_owned()))
}
fn setting_changed(setting: &SyncedSetting) -> CommunicationValue {
CommunicationValue::new(CommunicationType::SyncedSettingChanged)
.with_receiver(sender_wire_id(setting.user_id))
@ -1703,6 +1922,410 @@ pub fn handle_synced_settings_list(cv: &CommunicationValue) -> CommunicationValu
}
}
pub fn handle_user_blob_put(cv: &CommunicationValue) -> BlobMutation {
let Ok((user_id, blob_id)) = blob_request(cv) else {
return BlobMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
};
};
let Some(blob) = cv.get_data(DataType::Blob).and_then(DataValue::as_bytes) else {
return BlobMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
};
};
let expected_revision = data_i64(cv, DataType::ExpectedRevision);
match user_blobs::put(user_id, &blob_id, &blob, expected_revision) {
Ok(stored) => BlobMutation {
response: blob_response(cv, CommunicationType::UserBlobPut, &stored),
changed: Some(blob_changed(
user_id,
stored.blob_id.clone(),
stored.revision,
false,
)),
},
Err(iota_storage::storage_error::StorageError::RevisionConflict) => BlobMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
},
Err(_) => BlobMutation {
response: error_response(cv, CommunicationType::ErrorInternal),
changed: None,
},
}
}
pub fn handle_user_blob_get(cv: &CommunicationValue) -> CommunicationValue {
let Ok((user_id, blob_id)) = blob_request(cv) else {
return error_response(cv, CommunicationType::ErrorInvalidData);
};
match user_blobs::get(user_id, &blob_id) {
Ok(Some(blob)) => blob_response(cv, CommunicationType::UserBlobGet, &blob),
Ok(None) => error_response(cv, CommunicationType::ErrorNotFound),
Err(_) => error_response(cv, CommunicationType::ErrorInternal),
}
}
pub fn handle_user_blob_delete(cv: &CommunicationValue) -> BlobMutation {
let Ok((user_id, blob_id)) = blob_request(cv) else {
return BlobMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
};
};
match user_blobs::delete(user_id, &blob_id, data_i64(cv, DataType::ExpectedRevision)) {
Ok(Some(deleted)) => BlobMutation {
response: CommunicationValue::new(CommunicationType::UserBlobDelete)
.with_request_id(cv)
.with_receiver(sender_wire_id(user_id))
.add_typed_default(DataType::BlobId, DataValue::Str(blob_id.clone()))
.add_typed_default(
DataType::VersionNumber,
DataValue::SignedNumber(deleted.revision.into()),
),
changed: deleted
.changed
.then(|| blob_changed(user_id, blob_id, deleted.revision, true)),
},
Ok(None) => BlobMutation {
response: CommunicationValue::new(CommunicationType::UserBlobDelete)
.with_request_id(cv)
.with_receiver(sender_wire_id(user_id)),
changed: None,
},
Err(iota_storage::storage_error::StorageError::RevisionConflict) => BlobMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
},
Err(_) => BlobMutation {
response: error_response(cv, CommunicationType::ErrorInternal),
changed: None,
},
}
}
pub fn handle_user_blob_list(cv: &CommunicationValue) -> CommunicationValue {
let Ok(user_id) = required_sender_id(cv) else {
return error_response(cv, CommunicationType::ErrorInvalidData);
};
if user_id <= 0 {
return error_response(cv, CommunicationType::ErrorInvalidData);
}
match user_blobs::list(user_id) {
Ok(blobs) => CommunicationValue::new(CommunicationType::UserBlobList)
.with_request_id(cv)
.with_receiver(sender_wire_id(user_id))
.add_typed_default(
DataType::Blobs,
DataValue::Array(blobs.iter().map(blob_value).collect()),
),
Err(_) => error_response(cv, CommunicationType::ErrorInternal),
}
}
fn authenticated_user(cv: &CommunicationValue) -> Result<i64, CommunicationValue> {
let user_id = required_sender_id(cv)?;
if user_id <= 0 {
return Err(error_response(cv, CommunicationType::ErrorInvalidData));
}
Ok(user_id)
}
pub fn handle_user_block(cv: &CommunicationValue) -> PolicyMutation {
let Ok(user_id) = authenticated_user(cv) else {
return PolicyMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
};
};
let Some(blocked_user_id) = data_i64(cv, DataType::BlockedUserId).filter(|id| *id > 0) else {
return PolicyMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
};
};
match blocked_users::block(user_id, blocked_user_id) {
Ok(record) => PolicyMutation {
response: CommunicationValue::new(CommunicationType::UserBlock)
.with_request_id(cv)
.with_receiver(sender_wire_id(user_id))
.add_typed_default(
DataType::BlockedUserId,
DataValue::SignedNumber(blocked_user_id.into()),
)
.add_typed_default(
DataType::VersionNumber,
DataValue::SignedNumber(record.revision.into()),
),
changed: Some(
CommunicationValue::new(CommunicationType::BlockedUsersChanged)
.with_id(next_notification_id())
.with_receiver(sender_wire_id(user_id))
.add_typed_default(
DataType::BlockedUserId,
DataValue::SignedNumber(blocked_user_id.into()),
)
.add_typed_default(
DataType::VersionNumber,
DataValue::SignedNumber(record.revision.into()),
)
.add_typed_default(DataType::Deleted, DataValue::Bool(record.deleted)),
),
},
Err(_) => PolicyMutation {
response: error_response(cv, CommunicationType::ErrorInternal),
changed: None,
},
}
}
pub fn handle_user_unblock(cv: &CommunicationValue) -> PolicyMutation {
let Ok(user_id) = authenticated_user(cv) else {
return PolicyMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
};
};
let Some(blocked_user_id) = data_i64(cv, DataType::BlockedUserId).filter(|id| *id > 0) else {
return PolicyMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
};
};
match blocked_users::unblock(user_id, blocked_user_id) {
Ok(mutation) => PolicyMutation {
response: CommunicationValue::new(CommunicationType::UserUnblock)
.with_request_id(cv)
.with_receiver(sender_wire_id(user_id))
.add_typed_default(
DataType::BlockedUserId,
DataValue::SignedNumber(blocked_user_id.into()),
),
changed: mutation.map(|mutation| {
CommunicationValue::new(CommunicationType::BlockedUsersChanged)
.with_id(next_notification_id())
.with_receiver(sender_wire_id(user_id))
.add_typed_default(
DataType::BlockedUserId,
DataValue::SignedNumber(blocked_user_id.into()),
)
.add_typed_default(
DataType::VersionNumber,
DataValue::SignedNumber(mutation.revision.into()),
)
.add_typed_default(DataType::Deleted, DataValue::Bool(mutation.deleted))
}),
},
Err(_) => PolicyMutation {
response: error_response(cv, CommunicationType::ErrorInternal),
changed: None,
},
}
}
pub fn handle_blocked_users_get(cv: &CommunicationValue) -> CommunicationValue {
let Ok(user_id) = authenticated_user(cv) else {
return error_response(cv, CommunicationType::ErrorInvalidData);
};
match blocked_users::list(user_id) {
Ok(users) => CommunicationValue::new(CommunicationType::BlockedUsersGet)
.with_request_id(cv)
.with_receiver(sender_wire_id(user_id))
.add_typed_default(
DataType::BlockedUserIds,
DataValue::Array(
users
.into_iter()
.map(|id| DataValue::SignedNumber(id.into()))
.collect(),
),
),
Err(_) => error_response(cv, CommunicationType::ErrorInternal),
}
}
fn receipt_response(
cv: &CommunicationValue,
ty: CommunicationType,
policy: receipt_policy::ReceiptPolicy,
) -> CommunicationValue {
CommunicationValue::new(ty)
.with_request_id(cv)
.with_receiver(sender_wire_id(policy.user_id))
.add_typed_default(
DataType::SendReadReceipts,
DataValue::Bool(policy.send_read_receipts),
)
.add_typed_default(
DataType::SendReceivedReceipts,
DataValue::Bool(policy.send_received_receipts),
)
.add_typed_default(
DataType::VersionNumber,
DataValue::SignedNumber(policy.revision.into()),
)
}
pub fn handle_receipt_policy_get(cv: &CommunicationValue) -> CommunicationValue {
let Ok(user_id) = authenticated_user(cv) else {
return error_response(cv, CommunicationType::ErrorInvalidData);
};
match receipt_policy::get(user_id) {
Ok(policy) => receipt_response(cv, CommunicationType::ReceiptPolicyGet, policy),
Err(_) => error_response(cv, CommunicationType::ErrorInternal),
}
}
pub fn handle_receipt_policy_set(cv: &CommunicationValue) -> PolicyMutation {
let Ok(user_id) = authenticated_user(cv) else {
return PolicyMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
};
};
let (Some(read), Some(received)) = (
cv.get_data(DataType::SendReadReceipts)
.and_then(DataValue::as_bool),
cv.get_data(DataType::SendReceivedReceipts)
.and_then(DataValue::as_bool),
) else {
return PolicyMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
};
};
match receipt_policy::set(user_id, read, received) {
Ok(policy) => {
let response = receipt_response(cv, CommunicationType::ReceiptPolicySet, policy);
let changed = receipt_response(cv, CommunicationType::ReceiptPolicyChanged, policy)
.with_id(next_notification_id());
PolicyMutation {
response,
changed: Some(changed),
}
}
Err(_) => PolicyMutation {
response: error_response(cv, CommunicationType::ErrorInternal),
changed: None,
},
}
}
fn storage_policy_response(
cv: &CommunicationValue,
ty: CommunicationType,
policy: message_storage_policy::MessageStoragePolicy,
) -> CommunicationValue {
let (history_mode, duration) = match policy.history_mode {
message_storage_policy::MessageHistoryMode::Retain => ("retain", None),
message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery => {
("delete_after_client_delivery", None)
}
};
let retention_duration = match policy.retention {
message_storage_policy::MessageRetention::Forever => None,
message_storage_policy::MessageRetention::Duration { duration_ms } => Some(duration_ms),
};
let mut response = CommunicationValue::new(ty)
.with_request_id(cv)
.with_receiver(sender_wire_id(policy.user_id))
.add_typed_default(
DataType::MessageHistoryMode,
DataValue::Str(history_mode.into()),
)
.add_typed_default(
DataType::VersionNumber,
DataValue::SignedNumber(policy.revision.into()),
);
if let Some(duration_ms) = retention_duration.or(duration) {
response = response.add_typed_default(
DataType::MessageRetentionDuration,
DataValue::SignedNumber(duration_ms.into()),
);
}
response
}
pub fn handle_message_storage_policy_get(cv: &CommunicationValue) -> CommunicationValue {
let Ok(user_id) = authenticated_user(cv) else {
return error_response(cv, CommunicationType::ErrorInvalidData);
};
match message_storage_policy::get(user_id) {
Ok(policy) => {
storage_policy_response(cv, CommunicationType::MessageStoragePolicyGet, policy)
}
Err(_) => error_response(cv, CommunicationType::ErrorInternal),
}
}
pub fn handle_message_storage_policy_set(cv: &CommunicationValue) -> PolicyMutation {
let Ok(user_id) = authenticated_user(cv) else {
return PolicyMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
};
};
let Some(history_mode) = cv
.get_data(DataType::MessageHistoryMode)
.and_then(DataValue::as_str)
else {
return PolicyMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
};
};
let history_mode = match history_mode {
"retain" => message_storage_policy::MessageHistoryMode::Retain,
"delete_after_client_delivery" => {
message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery
}
_ => {
return PolicyMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
};
}
};
let retention = match data_i64(cv, DataType::MessageRetentionDuration) {
Some(duration_ms) => message_storage_policy::MessageRetention::Duration { duration_ms },
None => message_storage_policy::MessageRetention::Forever,
};
match message_storage_policy::set(user_id, history_mode, retention) {
Ok(policy) => {
let response =
storage_policy_response(cv, CommunicationType::MessageStoragePolicySet, policy);
let changed =
storage_policy_response(cv, CommunicationType::MessageStoragePolicyChanged, policy)
.with_id(next_notification_id());
PolicyMutation {
response,
changed: Some(changed),
}
}
Err(_) => PolicyMutation {
response: error_response(cv, CommunicationType::ErrorInvalidData),
changed: None,
},
}
}
/* This request is issued over Omikron's trusted Iota connection. Clients have
* no route to it, preventing arbitrary block-relationship disclosure. */
pub fn handle_user_block_check(cv: &CommunicationValue) -> CommunicationValue {
let Some(sender_id) = data_i64(cv, DataType::SenderId).filter(|id| *id > 0) else {
return error_response(cv, CommunicationType::ErrorInvalidData);
};
let Some(receiver_id) = data_i64(cv, DataType::ReceiverId).filter(|id| *id > 0) else {
return error_response(cv, CommunicationType::ErrorInvalidData);
};
match blocked_users::is_blocked(receiver_id, sender_id) {
Ok(blocked) => CommunicationValue::new(CommunicationType::UserBlockCheck)
.with_request_id(cv)
.add_typed_default(DataType::IsBlocked, DataValue::Bool(blocked)),
Err(_) => error_response(cv, CommunicationType::ErrorInternal),
}
}
#[cfg(test)]
mod synced_settings_tests {
use super::{handle_synced_setting_get, handle_synced_setting_set, parse_setting_locator};

View file

@ -238,6 +238,12 @@ impl CommandRouter {
LocalRequest::PurgeUserData { user_id } => match user_manager::purge_user_data(user_id)
{
Ok(()) => ResponseResult::Ok(ResponsePayload::UserDataPurged { user_id }),
Err(iota_storage::storage_error::StorageError::PendingRelayOwnershipUnknown) => {
log!(
"User data purge is waiting for pending relay ownership classification for {user_id}"
);
ResponseResult::Error(IpcErrorCode::StorageFailure)
}
Err(error) => {
log!("User data purge failed for {user_id}: {error}");
ResponseResult::Error(IpcErrorCode::StorageFailure)

View file

@ -9,6 +9,9 @@ use std::process::ExitCode;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{broadcast, watch};
const MESSAGE_RETENTION_INTERVAL: Duration = Duration::from_secs(60);
const SYNC_COMPACTION_INTERVAL: Duration = Duration::from_secs(60 * 60);
#[tokio::main(flavor = "multi_thread")]
async fn main() -> ExitCode {
let scope = match std::env::var("IOTA_DEPLOYMENT_MODE").ok().as_deref() {
@ -357,6 +360,65 @@ async fn main() -> ExitCode {
runtime.set_startup_phase(StartupPhase::Ready);
log!("iota-daemon started (phase: Ready)");
let retention_runtime = runtime.clone();
runtime
.tasks
.spawn_tracked("message-retention", async move {
loop {
let purge = tokio::task::spawn_blocking(|| {
iota_storage::util::message_retention::purge_expired_messages(
iota_storage::util::sync::now_millis(),
)
})
.await;
match purge {
Ok(Ok(result)) if result.deleted_messages > 0 => {
log!("purged {} expired messages", result.deleted_messages);
}
Ok(Ok(_)) => {}
Ok(Err(error)) => log!("message retention cleanup failed: {}", error),
Err(error) => log!("message retention task failed: {}", error),
}
tokio::select! {
_ = tokio::time::sleep(MESSAGE_RETENTION_INTERVAL) => {},
_ = retention_runtime.cancellation.cancelled() => break,
}
}
Ok(())
})
.await;
let compaction_runtime = runtime.clone();
runtime
.tasks
.spawn_tracked("sync-compaction", async move {
loop {
let compact =
tokio::task::spawn_blocking(iota_storage::util::sync::compact_all_sync_state)
.await;
match compact {
Ok(Ok(result))
if result.removed_events > 0 || result.removed_blob_tombstones > 0 =>
{
log!(
"compacted {} sync events and {} blob tombstones",
result.removed_events,
result.removed_blob_tombstones
);
}
Ok(Ok(_)) => {}
Ok(Err(error)) => log!("sync compaction failed: {}", error),
Err(error) => log!("sync compaction task failed: {}", error),
}
tokio::select! {
_ = tokio::time::sleep(SYNC_COMPACTION_INTERVAL) => {},
_ = compaction_runtime.cancellation.cancelled() => break,
}
}
Ok(())
})
.await;
// --- Main lifecycle loop ---
let signal = async {
#[cfg(unix)]

View file

@ -10,6 +10,10 @@ pub enum StorageError {
Io(#[from] std::io::Error),
#[error("message has reached the unique reaction limit")]
ReactionLimitReached,
#[error("revision conflict")]
RevisionConflict,
#[error("pending relay ownership is unknown")]
PendingRelayOwnershipUnknown,
#[error("{0}")]
Other(String),
}

View file

@ -255,8 +255,15 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr
/// Authoritative hosted-data erasure used by local purge and future Omega
/// erasure delivery. Management metadata and credentials are left intact.
pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
if crate::util::relay_queue::has_unclassified_relays()? {
return Err(crate::storage_error::StorageError::PendingRelayOwnershipUnknown);
}
db::with_db(|conn| {
let tx = conn.unchecked_transaction()?;
tx.execute(
"DELETE FROM message_receipts WHERE storage_owner = ?1",
params![user_id],
)?;
tx.execute("DELETE FROM message_edits WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?;
tx.execute("DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?;
tx.execute(
@ -276,6 +283,22 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage
"DELETE FROM synced_settings WHERE user_id = ?1",
params![user_id],
)?;
tx.execute(
"DELETE FROM user_blobs WHERE user_id = ?1",
params![user_id],
)?;
tx.execute(
"DELETE FROM blocked_users WHERE user_id = ?1",
params![user_id],
)?;
tx.execute(
"DELETE FROM user_receipt_policy WHERE user_id = ?1",
params![user_id],
)?;
tx.execute(
"DELETE FROM user_message_storage_policy WHERE user_id = ?1",
params![user_id],
)?;
tx.execute(
"DELETE FROM sync_events WHERE user_id = ?1",
params![user_id],
@ -288,10 +311,26 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage
"DELETE FROM client_sync_state WHERE user_id = ?1",
params![user_id],
)?;
tx.execute(
"DELETE FROM client_message_deliveries WHERE user_id = ?1",
params![user_id],
)?;
tx.execute(
"DELETE FROM trusted_apps WHERE user_id = ?1",
params![user_id],
)?;
tx.execute(
"DELETE FROM pending_relays WHERE relay_signer_id = ?1 OR relay_destination_user_id = ?1",
params![user_id],
)?;
tx.execute(
"DELETE FROM relay_replay WHERE EXISTS (SELECT 1 FROM relay_inbox WHERE relay_inbox.signer_id = relay_replay.signer_id AND relay_inbox.message_id = relay_replay.message_id AND (relay_inbox.signer_id = ?1 OR relay_inbox.destination_id = ?1))",
params![user_id],
)?;
tx.execute(
"DELETE FROM relay_inbox WHERE signer_id = ?1 OR destination_id = ?1",
params![user_id],
)?;
tx.execute(
"UPDATE user_residency SET data_state = 'empty', updated_at = ?2 WHERE user_id = ?1",
params![user_id, now_millis()],

View file

@ -0,0 +1,104 @@
/* Blocks are user policy independent of contacts or conversations. */
use crate::storage_error::StorageError;
use crate::util::{db, sync};
use rusqlite::{OptionalExtension, params};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BlockMutation {
pub blocked_user_id: i64,
pub revision: i64,
pub deleted: bool,
}
fn validate(user_id: i64, blocked_user_id: i64) -> Result<(), StorageError> {
if user_id <= 0 || blocked_user_id <= 0 || user_id == blocked_user_id {
Err(StorageError::Other("invalid block relationship".into()))
} else {
Ok(())
}
}
pub fn block(user_id: i64, blocked_user_id: i64) -> Result<BlockMutation, StorageError> {
validate(user_id, blocked_user_id)?;
db::with_immediate_transaction(|tx| {
let prior = tx.query_row("SELECT id, revision FROM blocked_users WHERE user_id = ?1 AND blocked_user_id = ?2", params![user_id, blocked_user_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))).optional()?;
if let Some((_, revision)) = prior {
return Ok(BlockMutation {
blocked_user_id,
revision,
deleted: false,
});
}
let created_at = sync::now_millis();
tx.execute("INSERT INTO blocked_users (user_id, blocked_user_id, revision, created_at) VALUES (?1, ?2, 0, ?3)", params![user_id, blocked_user_id, created_at])?;
let id = tx.last_insert_rowid();
let revision = sync::record_event(
tx,
user_id,
sync::EntityType::BlockedUser,
id,
sync::Operation::Upsert,
)?;
tx.execute(
"UPDATE blocked_users SET revision = ?2 WHERE id = ?1",
params![id, revision],
)?;
Ok(BlockMutation {
blocked_user_id,
revision,
deleted: false,
})
})
}
pub fn unblock(user_id: i64, blocked_user_id: i64) -> Result<Option<BlockMutation>, StorageError> {
validate(user_id, blocked_user_id)?;
db::with_immediate_transaction(|tx| {
let id = tx
.query_row(
"SELECT id FROM blocked_users WHERE user_id = ?1 AND blocked_user_id = ?2",
params![user_id, blocked_user_id],
|r| r.get(0),
)
.optional()?;
let Some(id) = id else {
return Ok(None);
};
let revision = sync::record_event(
tx,
user_id,
sync::EntityType::BlockedUser,
id,
sync::Operation::Delete,
)?;
tx.execute("DELETE FROM blocked_users WHERE id = ?1", [id])?;
Ok(Some(BlockMutation {
blocked_user_id,
revision,
deleted: true,
}))
})
}
pub fn is_blocked(user_id: i64, candidate_sender_id: i64) -> Result<bool, StorageError> {
validate(user_id, candidate_sender_id)?;
db::with_db(|conn| {
Ok(conn
.query_row(
"SELECT 1 FROM blocked_users WHERE user_id = ?1 AND blocked_user_id = ?2",
params![user_id, candidate_sender_id],
|_| Ok(()),
)
.optional()?
.is_some())
})
}
pub fn list(user_id: i64) -> Result<Vec<i64>, StorageError> {
if user_id <= 0 {
return Err(StorageError::Other("invalid block owner".into()));
}
db::with_db(|conn| {
let mut stmt = conn.prepare(
"SELECT blocked_user_id FROM blocked_users WHERE user_id = ?1 ORDER BY blocked_user_id",
)?;
stmt.query_map([user_id], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()
.map_err(StorageError::from)
})
}

View file

@ -1,8 +1,9 @@
use crate::storage_error::StorageError;
use crate::util::db;
use crate::util::message_storage_policy::{self, MessageRetention};
use crate::util::sync::{self, EntityType, Operation};
use iota_logger::log;
use rusqlite::{OptionalExtension, params};
use rusqlite::{OptionalExtension, Transaction, params};
pub const MAX_UNIQUE_REACTIONS_PER_MESSAGE: usize = 10;
@ -149,7 +150,7 @@ fn update_message_content(
db::with_db(|conn| {
let msg = conn.query_row(
r#"
SELECT id, content, sent_by_self
SELECT id, content, sent_by_self, history_deleted
FROM messages
WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3
ORDER BY id DESC LIMIT 1
@ -160,11 +161,12 @@ fn update_message_content(
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
))
},
)?;
let (msg_id, old_content, sent_by_self) = msg;
let (msg_id, old_content, sent_by_self, history_deleted) = msg;
if require_sent_by_self && sent_by_self != 1 {
return Err(StorageError::Other(
"Only the original sender can edit this message".into(),
@ -176,6 +178,9 @@ fn update_message_content(
));
}
if history_deleted != 0 {
return Ok(());
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
@ -217,46 +222,144 @@ pub fn hard_delete_message(
message_time: i64,
) -> Result<(), StorageError> {
db::with_db(|conn| {
let msg_id: i64 = conn.query_row(
r#"
SELECT id FROM messages
let msg_id = conn
.query_row(
r#"
SELECT id, history_deleted FROM messages
WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3
ORDER BY id DESC LIMIT 1
"#,
params![storage_owner, external_user, message_time],
|row| row.get(0),
)?;
params![storage_owner, external_user, message_time],
|row| row.get::<_, i64>(0),
)
.optional()?;
let Some(msg_id) = msg_id else {
return Ok(());
};
let tx = conn.unchecked_transaction()?;
tx.execute(
"DELETE FROM message_edits WHERE message_id = ?1",
params![msg_id],
)?;
tx.execute(
"DELETE FROM reactions WHERE message_id = ?1",
params![msg_id],
)?;
tx.execute("DELETE FROM messages WHERE id = ?1", params![msg_id])?;
sync::record_event(
&tx,
storage_owner,
EntityType::Message,
msg_id,
Operation::Delete,
)?;
purge_message_in_tx(&tx, storage_owner, msg_id)?;
tx.commit()?;
Ok(())
})
}
pub fn purge_message(storage_owner: i64, message_id: i64) -> Result<(), StorageError> {
db::with_immediate_transaction(|tx| purge_message_in_tx(tx, storage_owner, message_id))
}
pub fn remove_message_history(storage_owner: i64, message_id: i64) -> Result<(), StorageError> {
db::with_immediate_transaction(|tx| remove_message_history_in_tx(tx, storage_owner, message_id))
}
/* Resolves the protocol identity retained in a tombstone before removing visible history. */
pub fn remove_message_history_by_relay_identity(
storage_owner: i64,
relay_signer_id: i64,
relay_message_id: &str,
) -> Result<(), StorageError> {
db::with_immediate_transaction(|tx| {
let message_id = tx.query_row(
"SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3",
params![storage_owner, relay_signer_id, relay_message_id],
|row| row.get::<_, i64>(0),
).optional()?;
if let Some(message_id) = message_id {
remove_message_history_in_tx(tx, storage_owner, message_id)?;
}
Ok(())
})
}
pub fn remove_message_history_in_tx(
tx: &Transaction<'_>,
storage_owner: i64,
message_id: i64,
) -> Result<(), StorageError> {
let message: Option<(i64, i64)> = tx
.query_row(
"SELECT external_user, history_deleted FROM messages WHERE id = ?1 AND storage_owner = ?2",
params![message_id, storage_owner],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
let Some((external_user, history_deleted)) = message else {
return Ok(());
};
if history_deleted != 0 {
return Ok(());
}
tx.execute(
"DELETE FROM message_edits WHERE message_id = ?1",
[message_id],
)?;
tx.execute("DELETE FROM reactions WHERE message_id = ?1", [message_id])?;
tx.execute("DELETE FROM message_receipts WHERE storage_owner = ?1 AND EXISTS (SELECT 1 FROM messages WHERE id = ?2 AND relay_signer_id = message_receipts.target_signer_id AND relay_message_id = message_receipts.target_message_id)", params![storage_owner, message_id])?;
tx.execute("UPDATE messages SET content = '', history_deleted = 1, history_deleted_at = ?2, expires_at = NULL, client_received_at = NULL, client_received_recorded_at = NULL, read_at = NULL, read_recorded_at = NULL WHERE id = ?1", params![message_id, sync::now_millis()])?;
sync::record_event(
tx,
storage_owner,
EntityType::Message,
message_id,
Operation::Delete,
)?;
update_contact_last_message_in_tx(tx, storage_owner, external_user)?;
Ok(())
}
fn update_contact_last_message_in_tx(
tx: &Transaction<'_>,
storage_owner: i64,
external_user: i64,
) -> Result<(), StorageError> {
tx.execute("UPDATE contacts SET last_message_at = (SELECT MAX(COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, message_time)) FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND deleted_by_external = 0 AND history_deleted = 0) WHERE storage_owner = ?1 AND user_id = ?2", params![storage_owner, external_user])?;
Ok(())
}
pub fn purge_message_in_tx(
tx: &Transaction<'_>,
storage_owner: i64,
message_id: i64,
) -> Result<(), StorageError> {
let (external_user, relay_signer_id, relay_message_id) = tx.query_row(
"SELECT external_user, relay_signer_id, relay_message_id FROM messages WHERE id = ?1 AND storage_owner = ?2",
params![message_id, storage_owner],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<i64>>(1)?, row.get::<_, Option<String>>(2)?)),
)?;
if let (Some(relay_signer_id), Some(relay_message_id)) = (relay_signer_id, relay_message_id) {
tx.execute(
"DELETE FROM message_receipts WHERE storage_owner = ?1 AND target_signer_id = ?2 AND target_message_id = ?3",
params![storage_owner, relay_signer_id, relay_message_id],
)?;
}
tx.execute(
"DELETE FROM message_edits WHERE message_id = ?1",
[message_id],
)?;
tx.execute("DELETE FROM reactions WHERE message_id = ?1", [message_id])?;
tx.execute("DELETE FROM messages WHERE id = ?1", [message_id])?;
sync::record_event(
tx,
storage_owner,
EntityType::Message,
message_id,
Operation::Delete,
)?;
update_contact_last_message_in_tx(tx, storage_owner, external_user)?;
Ok(())
}
/* Deletes a message from the sender's local copy after checking ownership. */
pub fn delete_message(
storage_owner: i64,
external_user: i64,
message_time: i64,
) -> Result<(), StorageError> {
ensure_message_direction(storage_owner, external_user, message_time, true)?;
hard_delete_message(storage_owner, external_user, message_time)
match ensure_message_direction(storage_owner, external_user, message_time, true) {
Ok(()) => hard_delete_message(storage_owner, external_user, message_time),
Err(StorageError::Db(rusqlite::Error::QueryReturnedNoRows)) => Ok(()),
Err(error) => Err(error),
}
}
/* Flags the recipient's local copy after validating its sender, preserving its history. */
@ -271,8 +374,11 @@ pub fn apply_remote_delete(
"Remote sender does not match chat partner".into(),
));
}
ensure_message_direction(storage_owner, external_user, message_time, false)?;
flag_deleted_by_external(storage_owner, external_user, message_time)
match ensure_message_direction(storage_owner, external_user, message_time, false) {
Ok(()) => flag_deleted_by_external(storage_owner, external_user, message_time),
Err(StorageError::Db(rusqlite::Error::QueryReturnedNoRows)) => Ok(()),
Err(error) => Err(error),
}
}
fn ensure_message_direction(
@ -330,6 +436,7 @@ pub fn flag_deleted_by_external(
msg_id,
Operation::Delete,
)?;
update_contact_last_message_in_tx(&tx, storage_owner, external_user)?;
tx.commit()?;
Ok(())
})
@ -346,15 +453,18 @@ pub fn delete_edit_history(
message_time: i64,
) -> Result<(), StorageError> {
db::with_db(|conn| {
let msg_id: i64 = conn.query_row(
let (msg_id, history_deleted): (i64, i64) = conn.query_row(
r#"
SELECT id FROM messages
SELECT id, history_deleted FROM messages
WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3
ORDER BY id DESC LIMIT 1
"#,
params![storage_owner, external_user, message_time],
|row| row.get(0),
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
if history_deleted != 0 {
return Ok(());
}
let tx = conn.unchecked_transaction()?;
tx.execute(
@ -381,15 +491,18 @@ pub fn add_reaction(
reaction: &str,
) -> Result<(), StorageError> {
db::with_immediate_transaction(|tx| {
let msg_id: i64 = tx.query_row(
let (msg_id, history_deleted): (i64, i64) = tx.query_row(
r#"
SELECT id FROM messages
SELECT id, history_deleted FROM messages
WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3
ORDER BY id DESC LIMIT 1
"#,
params![storage_owner, external_user, message_time],
|row| row.get(0),
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
if history_deleted != 0 {
return Ok(());
}
let reaction_exists: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM reactions WHERE message_id = ?1 AND reaction = ?2)",
@ -442,15 +555,18 @@ pub fn remove_reaction(
reaction: &str,
) -> Result<(), StorageError> {
db::with_db(|conn| {
let msg_id: i64 = conn.query_row(
let (msg_id, history_deleted): (i64, i64) = conn.query_row(
r#"
SELECT id FROM messages
SELECT id, history_deleted FROM messages
WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3
ORDER BY id DESC LIMIT 1
"#,
params![storage_owner, external_user, message_time],
|row| row.get(0),
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
if history_deleted != 0 {
return Ok(());
}
let tx = conn.unchecked_transaction()?;
tx.execute(
@ -485,6 +601,16 @@ pub fn add_message(message: NewMessage<'_>) -> Result<i64, StorageError> {
destination_iota_received_at,
initial_state,
} = message;
let stored_at = sync::now_millis();
let policy = message_storage_policy::get(storage_owner)?;
let expires_at = match policy.retention {
MessageRetention::Forever => None,
MessageRetention::Duration { duration_ms } => Some(
stored_at
.checked_add(duration_ms)
.ok_or_else(|| StorageError::Other("message expiry overflow".into()))?,
),
};
db::with_db(|conn| {
let tx = conn.unchecked_transaction()?;
tx.execute(
@ -492,8 +618,8 @@ pub fn add_message(message: NewMessage<'_>) -> Result<i64, StorageError> {
INSERT INTO messages (
storage_owner, external_user, message_time, content, sent_by_self,
message_state, height, reply_to, relay_signer_id, relay_message_id,
authored_at, origin_iota_received_at, destination_iota_received_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
authored_at, origin_iota_received_at, destination_iota_received_at, stored_at, expires_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
"#,
params![
storage_owner,
@ -509,6 +635,8 @@ pub fn add_message(message: NewMessage<'_>) -> Result<i64, StorageError> {
authored_at,
origin_iota_received_at,
destination_iota_received_at,
stored_at,
expires_at,
],
)?;
let msg_id = tx.last_insert_rowid();
@ -583,11 +711,11 @@ pub fn record_message_receipt(
};
db::with_db(|conn| {
let tx = conn.unchecked_transaction()?;
let Some((message_id, external_user, authored_at)) = tx
let Some((message_id, external_user, authored_at, history_deleted)) = tx
.query_row(
"SELECT id, external_user, authored_at FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3",
"SELECT id, external_user, authored_at, history_deleted FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3",
params![storage_owner, target_signer_id, target_message_id],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Option<i64>>(2)?)),
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Option<i64>>(2)?, row.get::<_, i64>(3)?)),
)
.optional()?
else {
@ -605,6 +733,9 @@ pub fn record_message_receipt(
"message receipt event time is outside the accepted clock range".into(),
));
}
if history_deleted != 0 {
return Ok(());
}
tx.execute(
"INSERT OR IGNORE INTO message_receipts (storage_owner, target_signer_id, target_message_id, receipt_signer_id, receipt_message_id, receipt_type, event_at, recorded_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![storage_owner, target_signer_id, target_message_id, receipt_signer_id, receipt_message_id, receipt_type, event_at, recorded_at],
@ -832,7 +963,7 @@ pub fn get_messages(
read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height,
reply_to, edited_count
FROM messages
WHERE storage_owner = ?1 AND external_user = ?2 AND deleted_by_external = 0
WHERE storage_owner = ?1 AND external_user = ?2 AND deleted_by_external = 0 AND history_deleted = 0
ORDER BY COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, id) DESC, id DESC
LIMIT ?3 OFFSET ?4
"#,
@ -908,6 +1039,7 @@ pub fn get_message(
WHERE storage_owner = ?1
AND message_time = ?2
AND deleted_by_external = 0
AND history_deleted = 0
AND (?3 IS NULL OR external_user = ?3)
ORDER BY id DESC
"#,
@ -977,6 +1109,7 @@ pub fn get_message_with_offset(
WHERE storage_owner = ?1
AND external_user = ?2
AND deleted_by_external = 0
AND history_deleted = 0
AND (
COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, id) >
COALESCE(?3, ?4)
@ -1005,7 +1138,7 @@ pub fn get_messages_by_ids(storage_owner: i64, ids: &[i64]) -> Vec<StoredMessage
// A journal id uniquely identifies a row. Load all messages for this owner and retain only
// those ids; this keeps reaction hydration identical to normal message loading.
match db::with_db(|conn| {
let mut stmt = conn.prepare("SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, origin_iota_received_at, destination_iota_received_at, client_received_at, client_received_recorded_at, read_at, read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, reply_to, edited_count, external_user FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0")?;
let mut stmt = conn.prepare("SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, origin_iota_received_at, destination_iota_received_at, client_received_at, client_received_recorded_at, read_at, read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, reply_to, edited_count, external_user FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0 AND history_deleted = 0")?;
let rows = stmt.query_map([storage_owner], |row| {
let external_user: i64 = row.get(19)?;
Ok(StoredMessage {
@ -1056,7 +1189,7 @@ pub fn get_messages_by_ids(storage_owner: i64, ids: &[i64]) -> Vec<StoredMessage
pub fn get_all_messages(storage_owner: i64) -> Vec<StoredMessage> {
let ids = match db::with_db(|conn| {
let mut stmt = conn.prepare(
"SELECT id FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0",
"SELECT id FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0 AND history_deleted = 0",
)?;
Ok(stmt
.query_map([storage_owner], |row| row.get::<_, i64>(0))?

View file

@ -0,0 +1,93 @@
/* Delivery records bind a specific state-sync response to the messages it contained. */
use crate::storage_error::StorageError;
use crate::util::{chat_files, db, message_storage_policy, sync};
use rusqlite::{OptionalExtension, params};
pub fn record_sync_delivery(
user_id: i64,
session_id: i64,
version: i64,
message_ids: impl IntoIterator<Item = i64>,
) -> Result<(), StorageError> {
if user_id <= 0 || session_id <= 0 || version < 0 {
return Err(StorageError::Other(
"invalid message delivery record".into(),
));
}
db::with_immediate_transaction(|tx| {
for message_id in message_ids {
tx.execute("INSERT OR IGNORE INTO client_message_deliveries (user_id, session_id, sync_version, message_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", params![user_id, session_id, version, message_id, sync::now_millis()])?;
}
Ok(())
})
}
pub fn acknowledge_sync_delivery(
user_id: i64,
session_id: i64,
version: i64,
) -> Result<Vec<i64>, StorageError> {
db::with_immediate_transaction(|tx| {
let mut statement = tx.prepare("SELECT DISTINCT message_id FROM client_message_deliveries WHERE user_id = ?1 AND session_id = ?2 AND sync_version <= ?3")?;
let ids = statement
.query_map(params![user_id, session_id, version], |row| row.get(0))?
.collect::<Result<Vec<i64>, _>>()
.map_err(StorageError::from)?;
tx.execute("DELETE FROM client_message_deliveries WHERE user_id = ?1 AND session_id = ?2 AND sync_version <= ?3", params![user_id, session_id, version])?;
Ok(ids)
})
}
/* Acknowledge a state-sync response and delete only its recorded visible messages atomically. */
pub fn acknowledge_client_state(
user_id: i64,
session_id: i64,
version: i64,
cache_schema_version: i64,
) -> Result<(), StorageError> {
if user_id <= 0 || session_id <= 0 || version < 0 {
return Err(StorageError::Other("invalid sync acknowledgement".into()));
}
db::with_immediate_transaction(|tx| {
let head = tx
.query_row(
"SELECT version FROM sync_heads WHERE user_id = ?1",
[user_id],
|row| row.get::<_, i64>(0),
)
.optional()?
.unwrap_or_default();
if version > head {
return Err(StorageError::Other(
"acknowledgement is ahead of head".into(),
));
}
let ids = {
let mut statement = tx.prepare("SELECT DISTINCT message_id FROM client_message_deliveries WHERE user_id = ?1 AND session_id = ?2 AND sync_version <= ?3")?;
statement
.query_map(params![user_id, session_id, version], |row| {
row.get::<_, i64>(0)
})?
.collect::<Result<Vec<_>, _>>()?
};
tx.execute("INSERT INTO client_sync_state (user_id, session_id, acknowledged_version, cache_schema_version, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(user_id, session_id) DO UPDATE SET acknowledged_version = MAX(acknowledged_version, excluded.acknowledged_version), cache_schema_version = excluded.cache_schema_version, updated_at = excluded.updated_at", params![user_id, session_id, version, cache_schema_version, sync::now_millis()])?;
if message_storage_policy::get_in_tx(tx, user_id)?.history_mode
== message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery
{
for message_id in ids {
chat_files::remove_message_history_in_tx(tx, user_id, message_id)?;
}
}
tx.execute("DELETE FROM client_message_deliveries WHERE user_id = ?1 AND session_id = ?2 AND sync_version <= ?3", params![user_id, session_id, version])?;
Ok(())
})
}
pub fn purge_session_deliveries(user_id: i64, session_id: i64) -> Result<usize, StorageError> {
db::with_db(|conn| {
Ok(conn.execute(
"DELETE FROM client_message_deliveries WHERE user_id = ?1 AND session_id = ?2",
params![user_id, session_id],
)?)
})
}

View file

@ -0,0 +1,39 @@
/* Commits client delivery, relay state, and retention cleanup as one operation. */
use crate::storage_error::StorageError;
use crate::util::{chat_files, db, message_storage_policy, relay_queue, relay_replay};
use rusqlite::OptionalExtension;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientRelayDeliveryResult {
NotFound,
Acknowledged,
}
pub fn acknowledge_client_delivery(
destination_id: i64,
frame_id: u32,
) -> Result<ClientRelayDeliveryResult, StorageError> {
db::with_immediate_transaction(|tx| {
let Some((pending_id, relay)) =
relay_queue::find_user_delivery_in_tx(tx, destination_id, frame_id)?
else {
return Ok(ClientRelayDeliveryResult::NotFound);
};
let policy = message_storage_policy::get_in_tx(tx, destination_id)?;
relay_replay::mark_delivered_for_frame_in_tx(tx, destination_id, frame_id)?;
if policy.history_mode
== message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery
{
let message_id = tx.query_row(
"SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3",
rusqlite::params![destination_id, relay.signer_id, relay.message_id],
|row| row.get::<_, i64>(0),
).optional()?;
if let Some(message_id) = message_id {
chat_files::remove_message_history_in_tx(tx, destination_id, message_id)?;
}
}
relay_queue::acknowledge_in_tx(tx, pending_id)?;
Ok(ClientRelayDeliveryResult::Acknowledged)
})
}

View file

@ -579,6 +579,137 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
conn.pragma_update(None, "user_version", 16)?;
}
if current_version < 17 {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS user_blobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
blob_id TEXT NOT NULL,
blob BLOB NOT NULL,
revision INTEGER NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0 CHECK (deleted IN (0, 1)),
updated_at INTEGER NOT NULL,
UNIQUE(user_id, blob_id)
);
CREATE INDEX IF NOT EXISTS idx_user_blobs_owner
ON user_blobs (user_id, deleted);
CREATE TABLE IF NOT EXISTS blocked_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
blocked_user_id INTEGER NOT NULL,
revision INTEGER NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(user_id, blocked_user_id)
);
CREATE INDEX IF NOT EXISTS idx_blocked_users_owner ON blocked_users (user_id);
CREATE TABLE IF NOT EXISTS user_receipt_policy (
user_id INTEGER PRIMARY KEY,
send_read_receipts INTEGER NOT NULL CHECK (send_read_receipts IN (0, 1)),
send_received_receipts INTEGER NOT NULL CHECK (send_received_receipts IN (0, 1)),
revision INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_message_storage_policy (
user_id INTEGER PRIMARY KEY,
history_mode TEXT NOT NULL CHECK (history_mode IN ('retain', 'delete_after_client_delivery')),
retention_mode TEXT NOT NULL CHECK (retention_mode IN ('forever', 'duration')),
retention_duration_ms INTEGER,
revision INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
PRAGMA user_version = 17;
"#,
)?;
}
if current_version < 18 {
let messages_exist: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages')",
[],
|row| row.get(0),
)?;
if messages_exist {
add_column_if_missing(conn, "stored_at", "stored_at INTEGER")?;
add_column_if_missing(conn, "expires_at", "expires_at INTEGER")?;
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_messages_expiry ON messages (storage_owner, expires_at) WHERE expires_at IS NOT NULL;",
)?;
}
conn.pragma_update(None, "user_version", 18)?;
}
if current_version < 19 {
let messages_exist: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages')",
[],
|row| row.get(0),
)?;
if messages_exist {
conn.execute(
"UPDATE messages SET stored_at = COALESCE(destination_iota_received_at, origin_iota_received_at) WHERE stored_at IS NULL",
[],
)?;
}
conn.pragma_update(None, "user_version", 19)?;
}
if current_version < 20 {
let messages_exist: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages')",
[],
|row| row.get(0),
)?;
if messages_exist {
add_column_if_missing(
conn,
"history_deleted",
"history_deleted INTEGER NOT NULL DEFAULT 0 CHECK (history_deleted IN (0, 1))",
)?;
add_column_if_missing(conn, "history_deleted_at", "history_deleted_at INTEGER")?;
}
let pending_relays_exist: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pending_relays')",
[],
|row| row.get(0),
)?;
if pending_relays_exist {
add_table_column_if_missing(
conn,
"pending_relays",
"relay_signer_id",
"relay_signer_id INTEGER",
)?;
add_table_column_if_missing(
conn,
"pending_relays",
"relay_destination_user_id",
"relay_destination_user_id INTEGER",
)?;
add_table_column_if_missing(
conn,
"pending_relays",
"relay_message_id",
"relay_message_id TEXT",
)?;
}
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS client_message_deliveries (
user_id INTEGER NOT NULL,
session_id INTEGER NOT NULL,
sync_version INTEGER NOT NULL,
message_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, session_id, sync_version, message_id)
);
CREATE INDEX IF NOT EXISTS idx_client_message_deliveries_session
ON client_message_deliveries (user_id, session_id, sync_version);
PRAGMA user_version = 20;
"#,
)?;
}
Ok(())
}
@ -648,7 +779,7 @@ mod tests {
run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 16);
assert_eq!(version, 20);
for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
@ -667,7 +798,7 @@ mod tests {
run_migrations_on_connection(&conn)?;
run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 16);
assert_eq!(version, 20);
for table in [
"sync_heads",
"sync_events",
@ -678,6 +809,10 @@ mod tests {
"relay_inbox",
"synced_settings",
"pending_user_operations",
"user_blobs",
"blocked_users",
"user_receipt_policy",
"user_message_storage_policy",
] {
let exists: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
@ -702,7 +837,7 @@ mod tests {
run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 16);
assert_eq!(version, 20);
for column in [
"id",
"user_id",

View file

@ -0,0 +1,69 @@
/* Completes or rejects an Iota-to-Iota relay without leaving retry state behind. */
use crate::storage_error::StorageError;
use crate::util::{db, sync};
use rusqlite::{OptionalExtension, params};
pub fn acknowledge_iota_delivery(
destination_iota: u64,
frame_id: u32,
signer_id: i64,
relay_message_id: &str,
destination_accepted_at: i64,
) -> Result<(), StorageError> {
let destination_iota = i64::try_from(destination_iota)
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
db::with_immediate_transaction(|tx| {
let pending_id = tx.query_row("SELECT id FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", params![destination_iota, i64::from(frame_id)], |row| row.get::<_, i64>(0)).optional()?;
let Some(pending_id) = pending_id else {
return Ok(());
};
let message_id = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?1 AND relay_message_id = ?2", params![signer_id, relay_message_id], |row| row.get::<_, i64>(0)).optional()?;
if let Some(message_id) = message_id {
tx.execute("UPDATE messages SET destination_iota_received_at = COALESCE(destination_iota_received_at, ?1), delivery_failed_at = NULL, delivery_failure = NULL, message_state = CASE WHEN message_state = 'sending' THEN 'sent' ELSE message_state END WHERE id = ?2", params![destination_accepted_at, message_id])?;
sync::record_event(
tx,
signer_id,
sync::EntityType::Message,
message_id,
sync::Operation::Upsert,
)?;
}
tx.execute("UPDATE relay_inbox SET state = 'delivered', downstream_acked_at = COALESCE(downstream_acked_at, ?3) WHERE signer_id = ?1 AND message_id = ?2", params![signer_id, relay_message_id, sync::now_millis()])?;
tx.execute("DELETE FROM pending_relays WHERE id = ?1", [pending_id])?;
Ok(())
})
}
pub fn reject_iota_delivery(
destination_iota: u64,
frame_id: u32,
signer_id: i64,
relay_message_id: &str,
failure: &str,
) -> Result<(), StorageError> {
let destination_iota = i64::try_from(destination_iota)
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
db::with_immediate_transaction(|tx| {
let pending_id = tx.query_row("SELECT id FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", params![destination_iota, i64::from(frame_id)], |row| row.get::<_, i64>(0)).optional()?;
let Some(pending_id) = pending_id else {
return Ok(());
};
let message_id = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?1 AND relay_message_id = ?2", params![signer_id, relay_message_id], |row| row.get::<_, i64>(0)).optional()?;
if let Some(message_id) = message_id {
tx.execute(
"UPDATE messages SET delivery_failed_at = ?1, delivery_failure = ?2 WHERE id = ?3",
params![sync::now_millis(), failure, message_id],
)?;
sync::record_event(
tx,
signer_id,
sync::EntityType::Message,
message_id,
sync::Operation::Upsert,
)?;
}
tx.execute("UPDATE relay_inbox SET state = 'rejected', rejected_at = COALESCE(rejected_at, ?3) WHERE signer_id = ?1 AND message_id = ?2", params![signer_id, relay_message_id, sync::now_millis()])?;
tx.execute("DELETE FROM pending_relays WHERE id = ?1", [pending_id])?;
Ok(())
})
}

View file

@ -0,0 +1,28 @@
/* Retention cleanup removes only stored history; pending transport is retained. */
use crate::storage_error::StorageError;
use crate::util::{chat_files, db};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct RetentionResult {
pub deleted_messages: usize,
}
pub fn purge_expired_messages(now: i64) -> Result<RetentionResult, StorageError> {
let expired = db::with_db(|conn| {
let mut statement = conn.prepare(
"SELECT id, storage_owner FROM messages WHERE expires_at IS NOT NULL AND expires_at <= ?1 ORDER BY expires_at ASC",
)?;
statement
.query_map([now], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
})?
.collect::<Result<Vec<_>, _>>()
.map_err(StorageError::from)
})?;
let mut result = RetentionResult::default();
for (message_id, storage_owner) in expired {
chat_files::purge_message(storage_owner, message_id)?;
result.deleted_messages += 1;
}
Ok(result)
}

View file

@ -0,0 +1,149 @@
/* Retention policy is normalized so invalid combinations cannot reach cleanup logic. */
use crate::storage_error::StorageError;
use crate::util::{db, sync};
use rusqlite::{OptionalExtension, Transaction};
pub const MIN_RETENTION_DURATION_MS: i64 = 60_000;
pub const MAX_RETENTION_DURATION_MS: i64 = 31_536_000_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageHistoryMode {
Retain,
DeleteAfterClientDelivery,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageRetention {
Forever,
Duration { duration_ms: i64 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MessageStoragePolicy {
pub user_id: i64,
pub history_mode: MessageHistoryMode,
pub retention: MessageRetention,
pub revision: i64,
pub updated_at: i64,
}
pub fn default_policy() -> MessageStoragePolicy {
MessageStoragePolicy {
user_id: 0,
history_mode: MessageHistoryMode::Retain,
retention: MessageRetention::Forever,
revision: 0,
updated_at: 0,
}
}
fn validate(user_id: i64, retention: MessageRetention) -> Result<(), StorageError> {
if user_id <= 0 {
return Err(StorageError::Other(
"invalid message storage policy owner".into(),
));
}
if let MessageRetention::Duration { duration_ms } = retention {
if !(MIN_RETENTION_DURATION_MS..=MAX_RETENTION_DURATION_MS).contains(&duration_ms) {
return Err(StorageError::Other("invalid retention duration".into()));
}
}
Ok(())
}
fn history_name(value: MessageHistoryMode) -> &'static str {
match value {
MessageHistoryMode::Retain => "retain",
MessageHistoryMode::DeleteAfterClientDelivery => "delete_after_client_delivery",
}
}
fn parse_history(value: &str) -> Result<MessageHistoryMode, StorageError> {
match value {
"retain" => Ok(MessageHistoryMode::Retain),
"delete_after_client_delivery" => Ok(MessageHistoryMode::DeleteAfterClientDelivery),
_ => Err(StorageError::Other(
"invalid stored message history mode".into(),
)),
}
}
pub fn get(user_id: i64) -> Result<MessageStoragePolicy, StorageError> {
validate(user_id, MessageRetention::Forever)?;
db::with_db(|conn| {
let found = conn.query_row("SELECT history_mode, retention_mode, retention_duration_ms, revision, updated_at FROM user_message_storage_policy WHERE user_id = ?1", [user_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, Option<i64>>(2)?, r.get::<_, i64>(3)?, r.get::<_, i64>(4)?))).optional()?;
policy_from_found(user_id, found)
})
}
pub fn get_in_tx(tx: &Transaction<'_>, user_id: i64) -> Result<MessageStoragePolicy, StorageError> {
validate(user_id, MessageRetention::Forever)?;
let found = tx.query_row("SELECT history_mode, retention_mode, retention_duration_ms, revision, updated_at FROM user_message_storage_policy WHERE user_id = ?1", [user_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, Option<i64>>(2)?, r.get::<_, i64>(3)?, r.get::<_, i64>(4)?))).optional()?;
policy_from_found(user_id, found)
}
fn policy_from_found(
user_id: i64,
found: Option<(String, String, Option<i64>, i64, i64)>,
) -> Result<MessageStoragePolicy, StorageError> {
match found {
None => Ok(MessageStoragePolicy {
user_id,
..default_policy()
}),
Some((history, mode, duration, revision, updated_at)) => {
let retention = match (mode.as_str(), duration) {
("forever", None) => MessageRetention::Forever,
("duration", Some(duration_ms)) => MessageRetention::Duration { duration_ms },
_ => {
return Err(StorageError::Other(
"invalid stored retention policy".into(),
));
}
};
validate(user_id, retention)?;
Ok(MessageStoragePolicy {
user_id,
history_mode: parse_history(&history)?,
retention,
revision,
updated_at,
})
}
}
}
pub fn set(
user_id: i64,
history_mode: MessageHistoryMode,
retention: MessageRetention,
) -> Result<MessageStoragePolicy, StorageError> {
validate(user_id, retention)?;
db::with_immediate_transaction(|tx| {
let revision = sync::record_event(
tx,
user_id,
sync::EntityType::MessageStoragePolicy,
user_id,
sync::Operation::Upsert,
)?;
let updated_at = sync::now_millis();
let (retention_mode, duration): (&str, Option<i64>) = match retention {
MessageRetention::Forever => ("forever", None),
MessageRetention::Duration { duration_ms } => ("duration", Some(duration_ms)),
};
tx.execute("INSERT INTO user_message_storage_policy (user_id, history_mode, retention_mode, retention_duration_ms, revision, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(user_id) DO UPDATE SET history_mode = excluded.history_mode, retention_mode = excluded.retention_mode, retention_duration_ms = excluded.retention_duration_ms, revision = excluded.revision, updated_at = excluded.updated_at", rusqlite::params![user_id, history_name(history_mode), retention_mode, duration, revision, updated_at])?;
match retention {
MessageRetention::Forever => {
tx.execute(
"UPDATE messages SET expires_at = NULL WHERE storage_owner = ?1",
[user_id],
)?;
}
MessageRetention::Duration { duration_ms } => {
tx.execute(
"UPDATE messages SET expires_at = stored_at + ?2 WHERE storage_owner = ?1 AND stored_at IS NOT NULL",
rusqlite::params![user_id, duration_ms],
)?;
}
}
Ok(MessageStoragePolicy {
user_id,
history_mode,
retention,
revision,
updated_at,
})
})
}

View file

@ -1,11 +1,20 @@
pub mod blocked_users;
pub mod chat_files;
pub mod chats_util;
pub mod client_message_delivery;
pub mod client_relay_delivery;
pub mod communities_util;
pub mod config_util;
pub mod db;
pub mod downstream_relay;
pub mod e2ee_storage;
pub mod message_retention;
pub mod message_storage_policy;
pub mod outgoing_relay;
pub mod receipt_policy;
pub mod relay_queue;
pub mod relay_replay;
pub mod settings;
pub mod sync;
pub mod synced_settings;
pub mod user_blobs;

View file

@ -0,0 +1,61 @@
/* Accepts an outgoing relay only when its retry record and retention state commit together. */
use crate::storage_error::StorageError;
use crate::util::{chat_files, db, message_storage_policy, relay_queue, relay_replay};
use iota_util::route_target::RouteTarget;
use rusqlite::OptionalExtension;
pub struct OutgoingRelay<'a> {
pub target: RouteTarget,
pub identity: &'a relay_queue::RelayIdentity,
pub frame: &'a [u8],
pub created_at: i64,
pub frame_id: u32,
pub type_map_version: &'a str,
}
pub fn commit_outgoing_relay(relay: OutgoingRelay<'_>) -> Result<(), StorageError> {
db::with_immediate_transaction(|tx| {
relay_queue::enqueue_in_tx(
tx,
relay.target,
relay.identity,
relay.frame,
relay.created_at,
relay.frame_id,
relay.type_map_version,
)?;
if message_storage_policy::get_in_tx(tx, relay.identity.signer_id)?.history_mode
== message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery
{
let message_id = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?1 AND relay_message_id = ?2", rusqlite::params![relay.identity.signer_id, relay.identity.message_id], |row| row.get::<_, i64>(0)).optional()?;
if let Some(message_id) = message_id {
chat_files::remove_message_history_in_tx(tx, relay.identity.signer_id, message_id)?;
}
}
relay_replay::mark_queued_in_tx(tx, relay.identity.signer_id, &relay.identity.message_id)?;
Ok(())
})
}
pub fn apply_outgoing_history_policy(
storage_owner: i64,
relay_signer_id: i64,
relay_message_id: &str,
) -> Result<(), StorageError> {
db::with_immediate_transaction(|tx| {
if message_storage_policy::get_in_tx(tx, storage_owner)?.history_mode
!= message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery
{
return Ok(());
}
let message_id = tx.query_row(
"SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3",
rusqlite::params![storage_owner, relay_signer_id, relay_message_id],
|row| row.get::<_, i64>(0),
).optional()?;
if let Some(message_id) = message_id {
chat_files::remove_message_history_in_tx(tx, storage_owner, message_id)?;
}
Ok(())
})
}

View file

@ -0,0 +1,64 @@
/* Receipt disclosure is typed policy because Iota enforces it when relaying states. */
use crate::storage_error::StorageError;
use crate::util::{db, sync};
use rusqlite::OptionalExtension;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReceiptPolicy {
pub user_id: i64,
pub send_read_receipts: bool,
pub send_received_receipts: bool,
pub revision: i64,
pub updated_at: i64,
}
pub fn default_policy() -> ReceiptPolicy {
ReceiptPolicy {
user_id: 0,
send_read_receipts: true,
send_received_receipts: false,
revision: 0,
updated_at: 0,
}
}
fn validate(user_id: i64) -> Result<(), StorageError> {
if user_id > 0 {
Ok(())
} else {
Err(StorageError::Other("invalid receipt policy owner".into()))
}
}
pub fn get(user_id: i64) -> Result<ReceiptPolicy, StorageError> {
validate(user_id)?;
db::with_db(|conn| {
let found = conn.query_row("SELECT send_read_receipts, send_received_receipts, revision, updated_at FROM user_receipt_policy WHERE user_id = ?1", [user_id], |r| Ok(ReceiptPolicy { user_id, send_read_receipts: r.get(0)?, send_received_receipts: r.get(1)?, revision: r.get(2)?, updated_at: r.get(3)? })).optional()?;
Ok(found.unwrap_or(ReceiptPolicy {
user_id,
..default_policy()
}))
})
}
pub fn set(
user_id: i64,
send_read_receipts: bool,
send_received_receipts: bool,
) -> Result<ReceiptPolicy, StorageError> {
validate(user_id)?;
db::with_immediate_transaction(|tx| {
let revision = sync::record_event(
tx,
user_id,
sync::EntityType::ReceiptPolicy,
user_id,
sync::Operation::Upsert,
)?;
let updated_at = sync::now_millis();
tx.execute("INSERT INTO user_receipt_policy (user_id, send_read_receipts, send_received_receipts, revision, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(user_id) DO UPDATE SET send_read_receipts = excluded.send_read_receipts, send_received_receipts = excluded.send_received_receipts, revision = excluded.revision, updated_at = excluded.updated_at", rusqlite::params![user_id, send_read_receipts, send_received_receipts, revision, updated_at])?;
Ok(ReceiptPolicy {
user_id,
send_read_receipts,
send_received_receipts,
revision,
updated_at,
})
})
}

View file

@ -1,7 +1,7 @@
use crate::storage_error::StorageError;
use crate::util::db;
use iota_util::route_target::RouteTarget;
use rusqlite::params;
use rusqlite::{OptionalExtension, Transaction, params};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PendingRelay {
@ -11,10 +11,43 @@ pub struct PendingRelay {
pub created_at: i64,
pub frame_id: u32,
pub type_map_version: String,
pub relay_signer_id: Option<i64>,
pub relay_destination_user_id: Option<i64>,
pub relay_message_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RelayIdentity {
pub signer_id: i64,
pub destination_user_id: i64,
pub message_id: String,
}
pub fn enqueue(
target: RouteTarget,
relay: &RelayIdentity,
frame: &[u8],
created_at: i64,
frame_id: u32,
type_map_version: &str,
) -> Result<(), StorageError> {
db::with_immediate_transaction(|tx| {
enqueue_in_tx(
tx,
target,
relay,
frame,
created_at,
frame_id,
type_map_version,
)
})
}
pub fn enqueue_in_tx(
tx: &Transaction<'_>,
target: RouteTarget,
relay: &RelayIdentity,
frame: &[u8],
created_at: i64,
frame_id: u32,
@ -26,26 +59,27 @@ pub fn enqueue(
RouteTarget::User(_) => 0_i64,
RouteTarget::Iota(_) => 1_i64,
};
db::with_db(|connection| {
connection.execute(
"INSERT OR IGNORE INTO pending_relays (destination_id, target_kind, frame, created_at, frame_id, type_map_version) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
tx.execute(
"INSERT OR IGNORE INTO pending_relays (destination_id, target_kind, relay_signer_id, relay_destination_user_id, relay_message_id, frame, created_at, frame_id, type_map_version) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
destination_id,
target_kind,
relay.signer_id,
relay.destination_user_id,
relay.message_id,
frame,
created_at,
i64::from(frame_id),
type_map_version
],
)?;
Ok(())
})
)?;
Ok(())
}
pub fn list(limit: i64) -> Result<Vec<PendingRelay>, StorageError> {
db::with_db(|connection| {
let mut statement = connection.prepare(
"SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version FROM pending_relays ORDER BY id LIMIT ?1",
"SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version, relay_signer_id, relay_destination_user_id, relay_message_id FROM pending_relays ORDER BY id LIMIT ?1",
)?;
let rows = statement.query_map(params![limit.clamp(1, 500)], |row| {
let destination_id = row.get::<_, i64>(1)?;
@ -81,12 +115,113 @@ pub fn list(limit: i64) -> Result<Vec<PendingRelay>, StorageError> {
)
})?,
type_map_version: row.get(6)?,
relay_signer_id: row.get(7)?,
relay_destination_user_id: row.get(8)?,
relay_message_id: row.get(9)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
})
}
pub fn list_without_relay_identity() -> Result<Vec<PendingRelay>, StorageError> {
list_without_relay_identity_after(0, i64::MAX)
}
pub fn list_without_relay_identity_after(
after_id: i64,
limit: i64,
) -> Result<Vec<PendingRelay>, StorageError> {
db::with_db(|connection| {
let mut statement = connection.prepare("SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version, relay_signer_id, relay_destination_user_id, relay_message_id FROM pending_relays WHERE id > ?1 AND (relay_signer_id IS NULL OR relay_destination_user_id IS NULL OR relay_message_id IS NULL) ORDER BY id LIMIT ?2")?;
let rows = statement.query_map(
params![after_id, limit.clamp(1, 500)],
pending_relay_from_row,
)?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
})
}
fn pending_relay_from_row(row: &rusqlite::Row<'_>) -> Result<PendingRelay, rusqlite::Error> {
let destination_id = u64::try_from(row.get::<_, i64>(1)?)
.map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, 0))?;
let target = match row.get::<_, i64>(2)? {
0 => RouteTarget::User(destination_id),
1 => RouteTarget::Iota(destination_id),
_ => return Err(rusqlite::Error::IntegralValueOutOfRange(2, 0)),
};
Ok(PendingRelay {
id: row.get(0)?,
target,
frame: row.get(3)?,
created_at: row.get(4)?,
frame_id: u32::try_from(row.get::<_, i64>(5)?)
.map_err(|_| rusqlite::Error::IntegralValueOutOfRange(5, 0))?,
type_map_version: row.get(6)?,
relay_signer_id: row.get(7)?,
relay_destination_user_id: row.get(8)?,
relay_message_id: row.get(9)?,
})
}
pub fn set_relay_identity(id: i64, relay: &RelayIdentity) -> Result<(), StorageError> {
db::with_db(|connection| {
connection.execute("UPDATE pending_relays SET relay_signer_id = ?2, relay_destination_user_id = ?3, relay_message_id = ?4 WHERE id = ?1", params![id, relay.signer_id, relay.destination_user_id, relay.message_id])?;
Ok(())
})
}
pub fn has_unclassified_relays() -> Result<bool, StorageError> {
db::with_db(|connection| {
connection
.query_row("SELECT EXISTS(SELECT 1 FROM pending_relays WHERE relay_signer_id IS NULL OR relay_destination_user_id IS NULL OR relay_message_id IS NULL)", [], |row| row.get(0))
.map_err(Into::into)
})
}
pub fn find_user_delivery_in_tx(
tx: &Transaction<'_>,
destination_id: i64,
frame_id: u32,
) -> Result<Option<(i64, RelayIdentity)>, StorageError> {
tx.query_row(
"SELECT id, relay_signer_id, relay_destination_user_id, relay_message_id FROM pending_relays WHERE destination_id = ?1 AND target_kind = 0 AND frame_id = ?2",
params![destination_id, i64::from(frame_id)],
|row| {
let signer_id = row.get::<_, Option<i64>>(1)?;
let destination_user_id = row.get::<_, Option<i64>>(2)?;
let message_id = row.get::<_, Option<String>>(3)?;
match (signer_id, destination_user_id, message_id) {
(Some(signer_id), Some(destination_user_id), Some(message_id)) => Ok((row.get(0)?, RelayIdentity { signer_id, destination_user_id, message_id })),
_ => Err(rusqlite::Error::InvalidQuery),
}
},
).optional().map_err(|error| match error {
rusqlite::Error::InvalidQuery => StorageError::PendingRelayOwnershipUnknown,
error => StorageError::Db(error),
})
}
pub fn acknowledge_in_tx(tx: &Transaction<'_>, id: i64) -> Result<(), StorageError> {
tx.execute("DELETE FROM pending_relays WHERE id = ?1", [id])?;
Ok(())
}
pub fn reject_outgoing_relay(
destination_iota: u64,
frame_id: u32,
signer_id: i64,
relay_message_id: &str,
) -> Result<(), StorageError> {
let destination_iota = i64::try_from(destination_iota)
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
db::with_immediate_transaction(|tx| {
tx.execute("DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", params![destination_iota, i64::from(frame_id)])?;
tx.execute("UPDATE relay_inbox SET state = 'rejected', rejected_at = COALESCE(rejected_at, ?3) WHERE signer_id = ?1 AND message_id = ?2", params![signer_id, relay_message_id, crate::util::sync::now_millis()])?;
Ok(())
})
}
pub fn acknowledge(destination_id: u64, frame_id: u32) -> Result<bool, StorageError> {
let destination_id = i64::try_from(destination_id)
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;

View file

@ -1,6 +1,13 @@
use crate::storage_error::StorageError;
use crate::util::db;
use rusqlite::params;
use rusqlite::{OptionalExtension, params};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeliveredRelay {
pub signer_id: i64,
pub message_id: String,
pub destination_id: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelayReservation {
@ -65,22 +72,52 @@ pub fn reserve(
})
}
pub fn mark_delivered_for_frame(destination_id: u64, frame_id: u32) -> Result<(), StorageError> {
pub fn mark_delivered_for_frame(
destination_id: u64,
frame_id: u32,
) -> Result<Option<DeliveredRelay>, StorageError> {
let destination_id = i64::try_from(destination_id)
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
db::with_db(|connection| {
db::with_immediate_transaction(|tx| {
let delivered_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
connection.execute(
let relay = tx
.query_row(
"SELECT signer_id, message_id, destination_id FROM relay_inbox WHERE destination_id = ?1 AND frame_id = ?2",
params![destination_id, i64::from(frame_id)],
|row| Ok(DeliveredRelay { signer_id: row.get(0)?, message_id: row.get(1)?, destination_id: row.get(2)? }),
)
.optional()?;
if relay.is_none() {
return Ok(None);
}
tx.execute(
"UPDATE relay_inbox SET state = 'delivered', downstream_acked_at = COALESCE(downstream_acked_at, ?3) WHERE destination_id = ?1 AND frame_id = ?2",
params![destination_id, i64::from(frame_id), delivered_at],
)?;
Ok(())
Ok(relay)
})
}
pub fn mark_delivered_for_frame_in_tx(
tx: &rusqlite::Transaction<'_>,
destination_id: i64,
frame_id: u32,
) -> Result<(), StorageError> {
let changed = tx.execute(
"UPDATE relay_inbox SET state = 'delivered', downstream_acked_at = COALESCE(downstream_acked_at, ?3) WHERE destination_id = ?1 AND frame_id = ?2",
params![destination_id, i64::from(frame_id), crate::util::sync::now_millis()],
)?;
if changed == 0 {
return Err(StorageError::Other(
"pending relay has no relay inbox record".into(),
));
}
Ok(())
}
fn mark_transition(
signer_id: u64,
message_id: &str,
@ -110,6 +147,18 @@ pub fn mark_queued(signer_id: u64, message_id: &str) -> Result<(), StorageError>
mark_transition(signer_id, message_id, "queued", "queued_at")
}
pub fn mark_queued_in_tx(
tx: &rusqlite::Transaction<'_>,
signer_id: i64,
message_id: &str,
) -> Result<(), StorageError> {
tx.execute(
"UPDATE relay_inbox SET state = 'queued', queued_at = COALESCE(queued_at, ?3) WHERE signer_id = ?1 AND message_id = ?2",
params![signer_id, message_id, crate::util::sync::now_millis()],
)?;
Ok(())
}
pub fn mark_downstream_acked(signer_id: u64, message_id: &str) -> Result<(), StorageError> {
mark_transition(signer_id, message_id, "delivered", "downstream_acked_at")
}

View file

@ -1,16 +1,21 @@
/* Durable per-user state journal used by device cache synchronization. */
use crate::storage_error::StorageError;
use crate::util::db;
use rusqlite::{Transaction, params};
use rusqlite::{OptionalExtension, Transaction, params};
use std::collections::BTreeMap;
pub const CACHE_SCHEMA_VERSION: i64 = 3;
pub const CACHE_SCHEMA_VERSION: i64 = 4;
pub const STALE_CLIENT_SYNC_STATE_MS: i64 = 90 * 24 * 60 * 60 * 1_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntityType {
Message,
Contact,
Setting,
UserBlob,
BlockedUser,
ReceiptPolicy,
MessageStoragePolicy,
}
impl EntityType {
fn as_str(self) -> &'static str {
@ -18,6 +23,10 @@ impl EntityType {
Self::Message => "message",
Self::Contact => "contact",
Self::Setting => "setting",
Self::UserBlob => "user_blob",
Self::BlockedUser => "blocked_user",
Self::ReceiptPolicy => "receipt_policy",
Self::MessageStoragePolicy => "message_storage_policy",
}
}
}
@ -44,6 +53,19 @@ pub struct Delta {
pub deleted_contact_ids: Vec<i64>,
pub setting_upserts: Vec<i64>,
pub deleted_setting_ids: Vec<i64>,
pub blob_upserts: Vec<i64>,
pub deleted_blob_ids: Vec<i64>,
pub blocked_user_upserts: Vec<i64>,
pub deleted_blocked_user_ids: Vec<i64>,
pub receipt_policy_changed: bool,
pub message_storage_policy_changed: bool,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SyncCompactionResult {
pub removed_stale_clients: usize,
pub removed_events: usize,
pub removed_blob_tombstones: usize,
}
pub fn now_millis() -> i64 {
@ -104,6 +126,18 @@ pub fn has_session(user_id: i64, session_id: i64) -> Result<bool, StorageError>
})
}
pub fn acknowledged_version(user_id: i64, session_id: i64) -> Result<Option<i64>, StorageError> {
db::with_db(|conn| {
conn.query_row(
"SELECT acknowledged_version FROM client_sync_state WHERE user_id = ?1 AND session_id = ?2",
params![user_id, session_id],
|row| row.get(0),
)
.optional()
.map_err(StorageError::from)
})
}
pub fn acknowledge(
user_id: i64,
session_id: i64,
@ -131,6 +165,70 @@ pub fn acknowledge(
})
}
/* Retain deltas until every non-stale client has acknowledged them. */
pub fn compact_user_sync_state(user_id: i64) -> Result<SyncCompactionResult, StorageError> {
if user_id <= 0 {
return Err(StorageError::Other("invalid sync owner".into()));
}
let stale_before = now_millis().saturating_sub(STALE_CLIENT_SYNC_STATE_MS);
db::with_immediate_transaction(|tx| {
tx.execute(
"DELETE FROM client_message_deliveries WHERE user_id = ?1 AND session_id IN (SELECT session_id FROM client_sync_state WHERE user_id = ?1 AND updated_at < ?2)",
params![user_id, stale_before],
)?;
let removed_stale_clients = tx.execute(
"DELETE FROM client_sync_state WHERE user_id = ?1 AND updated_at < ?2",
params![user_id, stale_before],
)?;
let minimum_acknowledged: Option<i64> = tx.query_row(
"SELECT MIN(acknowledged_version) FROM client_sync_state WHERE user_id = ?1",
[user_id],
|row| row.get(0),
)?;
let revision = match minimum_acknowledged {
Some(revision) => revision,
None => tx
.query_row(
"SELECT version FROM sync_heads WHERE user_id = ?1",
[user_id],
|row| row.get(0),
)
.unwrap_or(0),
};
let removed_events = tx.execute(
"DELETE FROM sync_events WHERE user_id = ?1 AND version <= ?2",
params![user_id, revision],
)?;
let removed_blob_tombstones = tx.execute(
"DELETE FROM user_blobs WHERE user_id = ?1 AND deleted = 1 AND revision <= ?2",
params![user_id, revision],
)?;
Ok(SyncCompactionResult {
removed_stale_clients,
removed_events,
removed_blob_tombstones,
})
})
}
pub fn compact_all_sync_state() -> Result<SyncCompactionResult, StorageError> {
let users = db::with_db(|conn| {
let mut statement = conn.prepare("SELECT user_id FROM sync_heads")?;
statement
.query_map([], |row| row.get::<_, i64>(0))?
.collect::<Result<Vec<_>, _>>()
.map_err(StorageError::from)
})?;
let mut total = SyncCompactionResult::default();
for user_id in users {
let result = compact_user_sync_state(user_id)?;
total.removed_stale_clients += result.removed_stale_clients;
total.removed_events += result.removed_events;
total.removed_blob_tombstones += result.removed_blob_tombstones;
}
Ok(total)
}
/// Returns the final operation for each entity after `from_version`.
pub fn delta(user_id: i64, from_version: i64, captured_head: i64) -> Result<Delta, StorageError> {
if from_version < 0 || from_version > captured_head {
@ -163,6 +261,12 @@ fn reduce_events(events: BTreeMap<(String, i64), String>) -> Delta {
("contact", _) => out.contact_upserts.push(id),
("setting", "delete") => out.deleted_setting_ids.push(id),
("setting", _) => out.setting_upserts.push(id),
("user_blob", "delete") => out.deleted_blob_ids.push(id),
("user_blob", _) => out.blob_upserts.push(id),
("blocked_user", "delete") => out.deleted_blocked_user_ids.push(id),
("blocked_user", _) => out.blocked_user_upserts.push(id),
("receipt_policy", _) => out.receipt_policy_changed = true,
("message_storage_policy", _) => out.message_storage_policy_changed = true,
_ => {}
}
}
@ -215,6 +319,20 @@ mod tests {
assert!(delta.setting_upserts.is_empty());
}
#[test]
fn blob_delete_is_included_in_delta() {
let mut events = BTreeMap::new();
events.insert(
(EntityType::UserBlob.as_str().to_string(), 7),
Operation::Delete.as_str().to_string(),
);
let delta = reduce_events(events);
assert_eq!(delta.deleted_blob_ids, vec![7]);
assert!(delta.blob_upserts.is_empty());
}
#[test]
fn final_setting_operation_wins() {
let mut events = BTreeMap::new();

View file

@ -0,0 +1,256 @@
/* Opaque client-owned data is stored without interpreting its encrypted bytes. */
use crate::storage_error::StorageError;
use crate::util::{db, sync};
use rusqlite::{Connection, OptionalExtension, Row, Transaction, params};
pub const MAX_BLOB_ID_BYTES: usize = 256;
pub const MAX_BLOB_BYTES: usize = 1_048_576;
pub const MAX_USER_BLOB_BYTES: usize = 16_777_216;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserBlob {
pub id: i64,
pub user_id: i64,
pub blob_id: String,
pub blob: Vec<u8>,
pub revision: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeletedUserBlob {
pub id: i64,
pub blob_id: String,
pub revision: i64,
pub changed: bool,
}
fn validate(user_id: i64, blob_id: &str, blob: Option<&[u8]>) -> Result<(), StorageError> {
if user_id <= 0 {
return Err(StorageError::Other("invalid blob owner".into()));
}
if blob_id.is_empty() || blob_id.len() > MAX_BLOB_ID_BYTES {
return Err(StorageError::Other("invalid blob id".into()));
}
if let Some(blob) = blob {
if blob.len() > MAX_BLOB_BYTES {
return Err(StorageError::Other("blob exceeds size limit".into()));
}
}
Ok(())
}
fn from_row(row: &Row<'_>) -> rusqlite::Result<UserBlob> {
Ok(UserBlob {
id: row.get(0)?,
user_id: row.get(1)?,
blob_id: row.get(2)?,
blob: row.get(3)?,
revision: row.get(4)?,
updated_at: row.get(5)?,
})
}
fn load(tx: &Transaction<'_>, id: i64) -> Result<UserBlob, StorageError> {
Ok(tx.query_row("SELECT id, user_id, blob_id, blob, revision, updated_at FROM user_blobs WHERE id = ?1 AND deleted = 0", [id], from_row)?)
}
pub fn put(
user_id: i64,
blob_id: &str,
blob: &[u8],
expected_revision: Option<i64>,
) -> Result<UserBlob, StorageError> {
validate(user_id, blob_id, Some(blob))?;
db::with_immediate_transaction(|tx| {
let existing = tx
.query_row(
"SELECT id, revision, deleted, length(blob) FROM user_blobs WHERE user_id = ?1 AND blob_id = ?2",
params![user_id, blob_id],
|r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, bool>(2)?,
r.get::<_, i64>(3)?,
))
},
)
.optional()?;
let existing_id = if let Some((id, revision, deleted, _)) = existing {
if deleted {
if !matches!(expected_revision, Some(0)) {
return Err(StorageError::RevisionConflict);
}
} else if expected_revision != Some(revision) {
return Err(StorageError::RevisionConflict);
}
Some(id)
} else {
if !matches!(expected_revision, None | Some(0)) {
return Err(StorageError::RevisionConflict);
}
None
};
let current_size = existing
.filter(|(_, _, deleted, _)| !*deleted)
.map(|(_, _, _, size)| size)
.unwrap_or(0);
let aggregate: i64 = tx.query_row("SELECT COALESCE(SUM(length(blob)), 0) FROM user_blobs WHERE user_id = ?1 AND deleted = 0", [user_id], |r| r.get(0))?;
let proposed_size = aggregate
.checked_sub(current_size)
.and_then(|size| size.checked_add(blob.len() as i64))
.ok_or_else(|| StorageError::Other("user blob storage quota overflow".into()))?;
if proposed_size > MAX_USER_BLOB_BYTES as i64 {
return Err(StorageError::Other(
"user blob storage limit exceeded".into(),
));
}
let id = match existing_id {
Some(id) => id,
None => {
tx.execute("INSERT INTO user_blobs (user_id, blob_id, blob, revision, deleted, updated_at) VALUES (?1, ?2, ?3, 0, 0, ?4)", params![user_id, blob_id, blob, sync::now_millis()])?;
tx.last_insert_rowid()
}
};
let revision = sync::record_event(
tx,
user_id,
sync::EntityType::UserBlob,
id,
sync::Operation::Upsert,
)?;
tx.execute("UPDATE user_blobs SET blob = ?2, revision = ?3, deleted = 0, updated_at = ?4 WHERE id = ?1", params![id, blob, revision, sync::now_millis()])?;
load(tx, id)
})
}
pub fn get(user_id: i64, blob_id: &str) -> Result<Option<UserBlob>, StorageError> {
validate(user_id, blob_id, None)?;
db::with_db(|conn| get_on(conn, user_id, blob_id))
}
fn get_on(
conn: &Connection,
user_id: i64,
blob_id: &str,
) -> Result<Option<UserBlob>, StorageError> {
Ok(conn.query_row("SELECT id, user_id, blob_id, blob, revision, updated_at FROM user_blobs WHERE user_id = ?1 AND blob_id = ?2 AND deleted = 0", params![user_id, blob_id], from_row).optional()?)
}
pub fn list(user_id: i64) -> Result<Vec<UserBlob>, StorageError> {
list_by_query(
user_id,
"SELECT id, user_id, blob_id, blob, revision, updated_at FROM user_blobs WHERE user_id = ?1 AND deleted = 0",
&[],
)
}
pub fn list_by_ids(user_id: i64, ids: &[i64]) -> Result<Vec<UserBlob>, StorageError> {
if user_id <= 0 {
return Err(StorageError::Other("invalid blob owner".into()));
}
if ids.is_empty() {
return Ok(Vec::new());
}
db::with_db(|conn| {
let mut out = Vec::new();
for id in ids {
if let Some(blob) = conn.query_row("SELECT id, user_id, blob_id, blob, revision, updated_at FROM user_blobs WHERE user_id = ?1 AND id = ?2 AND deleted = 0", params![user_id, id], from_row).optional()? { out.push(blob); }
}
Ok(out)
})
}
pub fn list_deleted_by_ids(
user_id: i64,
ids: &[i64],
) -> Result<Vec<DeletedUserBlob>, StorageError> {
if user_id <= 0 {
return Err(StorageError::Other("invalid blob owner".into()));
}
if ids.is_empty() {
return Ok(Vec::new());
}
db::with_db(|conn| {
let mut deleted = Vec::new();
for id in ids {
if let Some(blob) = conn.query_row(
"SELECT id, blob_id, revision FROM user_blobs WHERE user_id = ?1 AND id = ?2 AND deleted = 1",
params![user_id, id],
|row| {
Ok(DeletedUserBlob {
id: row.get(0)?,
blob_id: row.get(1)?,
revision: row.get(2)?,
changed: true,
})
},
).optional()? {
deleted.push(blob);
}
}
Ok(deleted)
})
}
fn list_by_query(user_id: i64, query: &str, _: &[i64]) -> Result<Vec<UserBlob>, StorageError> {
if user_id <= 0 {
return Err(StorageError::Other("invalid blob owner".into()));
}
db::with_db(|conn| {
let mut statement = conn.prepare(query)?;
let rows = statement.query_map([user_id], from_row)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StorageError::from)
})
}
pub fn delete(
user_id: i64,
blob_id: &str,
expected_revision: Option<i64>,
) -> Result<Option<DeletedUserBlob>, StorageError> {
validate(user_id, blob_id, None)?;
db::with_immediate_transaction(|tx| {
let existing = tx
.query_row(
"SELECT id, revision, deleted FROM user_blobs WHERE user_id = ?1 AND blob_id = ?2",
params![user_id, blob_id],
|r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, bool>(2)?,
))
},
)
.optional()?;
let Some((id, current, deleted)) = existing else {
return Ok(None);
};
if deleted {
return Ok(Some(DeletedUserBlob {
id,
blob_id: blob_id.into(),
revision: current,
changed: false,
}));
}
if expected_revision != Some(current) {
return Err(StorageError::RevisionConflict);
}
let revision = sync::record_event(
tx,
user_id,
sync::EntityType::UserBlob,
id,
sync::Operation::Delete,
)?;
tx.execute(
"UPDATE user_blobs SET blob = X'', deleted = 1, revision = ?2, updated_at = ?3 WHERE id = ?1",
params![id, revision, sync::now_millis()],
)?;
Ok(Some(DeletedUserBlob {
id,
blob_id: blob_id.into(),
revision,
changed: true,
}))
})
}

@ -1 +1 @@
Subproject commit f4e45aa3a3ad0e3c3a257f66857b904a1af7901c
Subproject commit 4b82f4f8139ed9aa74fa86f73ba8f0d565703c86

View file

@ -1,9 +1,9 @@
use dashmap::{DashMap, DashSet};
use iota_logger::{log, log_cv_in, log_cv_out, log_t};
use iota_state::AppState;
use iota_storage::util::chat_files;
use iota_storage::util::config_util::{CONFIG, modify_config};
use iota_storage::util::{relay_queue, relay_replay};
use iota_storage::util::relay_replay;
use iota_storage::util::{chat_files, client_relay_delivery, outgoing_relay, relay_queue};
use iota_util::crypto_helper::{self, keyring_from_base64};
use iota_util::crypto_util::{self};
use mtp::client::{Client, ClientConfig, MTPConnection, Policy, SendMode, Sender};
@ -510,6 +510,9 @@ impl OmikronConnection {
log_t!("omikron_authenticated");
self.classify_legacy_pending_relays().await;
self.flush_pending_relays().await;
let maintenance_self = self.clone();
let maintenance_handle = tokio::spawn(async move {
maintenance_self.maintenance_loop(connection).await;
@ -753,6 +756,7 @@ impl OmikronConnection {
self.app.lock().unwrap().push_ping_val(ping_ms as f64);
}
self.classify_legacy_pending_relays().await;
self.flush_pending_relays().await;
if let Err(error) = relay_replay::prune_completed(
now_millis_i64().saturating_sub(RELAY_RETENTION_MILLIS),
@ -980,6 +984,74 @@ impl OmikronConnection {
return;
}
/* Evaluate recipient policy before reserving relay replay state or
* persisting the frame, so blocked traffic leaves no durable trace. */
if recipient_is_local {
match iota_storage::util::blocked_users::is_blocked(recipient_id, signer_id) {
Ok(true) => {
log!(
"Rejecting Relay from blocked signer {} to recipient {}",
signer_id,
recipient_id
);
self.send_relay_response(
Some(incoming_frame_id),
CommunicationType::ErrorNotAuthenticated,
)
.await;
return;
}
Ok(false) => {}
Err(error) => {
log!(
"Relay block policy lookup failed for recipient {}: {}",
recipient_id,
error
);
self.send_relay_response(
Some(incoming_frame_id),
CommunicationType::ErrorInternal,
)
.await;
return;
}
}
}
/* An origin Iota is authoritative for receipt disclosure. Inspect
* local-origin relay content before reserving or queuing the frame. */
if signer_is_local {
let content = match open_verified_relay_content(
&verified,
&[&keyring],
verified.context.signer_id,
) {
Ok(content) => content,
Err(error) => {
log!("Relay origin content verification failed: {}", error);
self.send_relay_response(
Some(incoming_frame_id),
CommunicationType::ErrorInvalidData,
)
.await;
return;
}
};
if let Err(error) = message_handlers::validate_outgoing_receipt_policy(
signer_id,
&verified.context,
&content,
) {
log!("Rejecting local receipt relay: {}", error);
self.send_relay_response(
Some(incoming_frame_id),
CommunicationType::ErrorInvalidData,
)
.await;
return;
}
}
let frame_bytes = match frame.clone().without_id().to_bytes() {
Ok(bytes) => bytes,
Err(error) => {
@ -1189,13 +1261,32 @@ impl OmikronConnection {
return;
}
};
if let Err(error) = relay_queue::enqueue(
RouteTarget::Iota(router),
&bytes,
now_millis_i64(),
frame_id,
&type_map_version,
let relay_identity = match (
i64::try_from(verified.context.signer_id),
i64::try_from(verified.context.final_recipient_id),
) {
(Ok(signer_id), Ok(destination_user_id)) => relay_queue::RelayIdentity {
signer_id,
destination_user_id,
message_id: verified.context.message_id.clone(),
},
_ => {
log!("Relay identity exceeds the storage range");
self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData)
.await;
return;
}
};
if let Err(error) =
outgoing_relay::commit_outgoing_relay(outgoing_relay::OutgoingRelay {
target: RouteTarget::Iota(router),
identity: &relay_identity,
frame: &bytes,
created_at: now_millis_i64(),
frame_id,
type_map_version: &type_map_version,
})
{
log!("Relay origin retry queue failed: {}", error);
record_origin_delivery_failure(
verified.context.signer_id,
@ -1206,13 +1297,8 @@ impl OmikronConnection {
.await;
return;
}
if let Err(error) =
relay_replay::mark_queued(verified.context.signer_id, &verified.context.message_id)
{
log!("Relay origin state update failed: {}", error);
}
match self
.await_response(&forwarded, Some(Duration::from_secs(20)))
.await_relay_response(&forwarded, Duration::from_secs(20))
.await
{
Ok(response) if response.is_type(CommunicationType::Success) => {
@ -1233,33 +1319,27 @@ impl OmikronConnection {
.await;
return;
};
if let (Ok(owner), Ok(signer)) = (
i64::try_from(verified.context.signer_id),
i64::try_from(verified.context.signer_id),
) {
if let Err(error) = chat_files::record_destination_iota_received(
owner,
signer,
let Ok(signer_id) = i64::try_from(verified.context.signer_id) else {
self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData)
.await;
return;
};
if let Err(error) =
iota_storage::util::downstream_relay::acknowledge_iota_delivery(
router,
frame_id,
signer_id,
&verified.context.message_id,
destination_accepted_at,
) {
log!(
"Relay destination acknowledgement storage failed: {}",
error
);
}
}
if let Err(error) = relay_queue::acknowledge_iota(router, frame_id) {
)
{
log!(
"Relay origin acknowledgement could not clear the queue: {}",
"Relay destination acknowledgement storage failed: {}",
error
);
}
if let Err(error) = relay_replay::mark_downstream_acked(
verified.context.signer_id,
&verified.context.message_id,
) {
log!("Relay origin delivery state update failed: {}", error);
self.send_relay_response(frame.id(), CommunicationType::ErrorInternal)
.await;
return;
}
let response = response
.add_typed_default(
@ -1277,11 +1357,30 @@ impl OmikronConnection {
}
Ok(response) => {
log!("Relay origin route returned {}", response.get_type());
record_origin_delivery_failure(
verified.context.signer_id,
&verified.context.message_id,
"destination_rejected",
);
if response.is_type(CommunicationType::ErrorInternal) {
record_origin_delivery_failure(
verified.context.signer_id,
&verified.context.message_id,
"destination_internal_error",
);
} else if let Ok(signer_id) = i64::try_from(verified.context.signer_id) {
if let Err(error) =
iota_storage::util::downstream_relay::reject_iota_delivery(
router,
frame_id,
signer_id,
&verified.context.message_id,
"destination_rejected",
)
{
log!("Relay rejection cleanup failed: {}", error);
}
record_origin_delivery_failure(
verified.context.signer_id,
&verified.context.message_id,
"destination_rejected",
);
}
self.send_relay_response(
frame.id(),
response
@ -1326,8 +1425,25 @@ impl OmikronConnection {
return;
}
};
let relay_identity = match (
i64::try_from(verified.context.signer_id),
i64::try_from(verified.context.final_recipient_id),
) {
(Ok(signer_id), Ok(destination_user_id)) => relay_queue::RelayIdentity {
signer_id,
destination_user_id,
message_id: verified.context.message_id.clone(),
},
_ => {
log!("Relay identity exceeds the storage range");
self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData)
.await;
return;
}
};
if let Err(error) = relay_queue::enqueue(
RouteTarget::User(destination),
&relay_identity,
&bytes,
now_millis_i64(),
frame_id,
@ -1404,6 +1520,24 @@ impl OmikronConnection {
}
}
if signer_is_local && recipient_is_local {
let Ok(owner) = i64::try_from(verified.context.signer_id) else {
self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData)
.await;
return;
};
if let Err(error) = outgoing_relay::apply_outgoing_history_policy(
owner,
owner,
&verified.context.message_id,
) {
log!("Shared-Iota outgoing history policy failed: {}", error);
self.send_relay_response(frame.id(), CommunicationType::ErrorInternal)
.await;
return;
}
}
if let Err(error) =
relay_replay::mark_queued(verified.context.signer_id, &verified.context.message_id)
{
@ -1422,11 +1556,117 @@ impl OmikronConnection {
}
}
async fn classify_legacy_pending_relays(&self) {
let mut after_id = 0;
loop {
let records = match relay_queue::list_without_relay_identity_after(after_id, 100) {
Ok(records) => records,
Err(error) => {
log!("Pending relay ownership query failed: {}", error);
return;
}
};
if records.is_empty() {
return;
}
for record in records {
after_id = record.id;
let Some(version) = mtp::type_map::Version::parse(&record.type_map_version) else {
log!(
"Deleting pending Relay {} with invalid type-map version",
record.id
);
let _ = relay_queue::delete(record.id);
continue;
};
let type_map = mtp::codec::TypeMap::new(version);
let Ok(frame) = CommunicationValue::from_bytes_with(&record.frame, &type_map)
else {
log!("Deleting pending Relay {} with invalid frame", record.id);
let _ = relay_queue::delete(record.id);
continue;
};
let Some(local_iota_id) = CONFIG.load().iota_id else {
return;
};
let Some(keyring) = self.keyring.read().await.as_ref().cloned() else {
return;
};
let verified = verify_relay_metadata(
&frame,
local_iota_id,
&keyring,
|signer_id| async move { self.resolve_relay_signing_keys(signer_id).await },
)
.await;
let verified = match verified {
Ok(verified) => verified,
Err(RelayValidationError::KeyLookup(error)) => {
log!(
"Deferring pending Relay {} ownership lookup: {}",
record.id,
error
);
continue;
}
Err(RelayValidationError::MissingSigningKeys(signer_id)) => {
log!(
"Deferring pending Relay {} until signer {} keys are available",
record.id,
signer_id
);
continue;
}
Err(error) => {
log!(
"Deleting structurally invalid pending Relay {}: {}",
record.id,
error
);
let _ = relay_queue::delete(record.id);
continue;
}
};
let identity = match (
i64::try_from(verified.context.signer_id),
i64::try_from(verified.context.final_recipient_id),
) {
(Ok(signer_id), Ok(destination_user_id)) => relay_queue::RelayIdentity {
signer_id,
destination_user_id,
message_id: verified.context.message_id,
},
_ => {
let _ = relay_queue::delete(record.id);
continue;
}
};
if let Err(error) = relay_queue::set_relay_identity(record.id, &identity) {
log!(
"Pending Relay {} ownership backfill failed: {}",
record.id,
error
);
}
}
}
}
async fn flush_pending_relays(&self) {
let Ok(records) = relay_queue::list(100) else {
return;
};
for record in records {
if record.relay_signer_id.is_none()
|| record.relay_destination_user_id.is_none()
|| record.relay_message_id.is_none()
{
log!(
"Skipping pending Relay {} until ownership is classified",
record.id
);
continue;
}
let Some(version) = mtp::type_map::Version::parse(&record.type_map_version) else {
log!(
"Retaining pending Relay {} with invalid type-map version {}",
@ -1440,6 +1680,72 @@ impl OmikronConnection {
log!("Retaining pending Relay {} with invalid frame", record.id);
continue;
};
if record.relay_signer_id.is_none()
|| record.relay_destination_user_id.is_none()
|| record.relay_message_id.is_none()
{
let Some(local_iota_id) = CONFIG.load().iota_id else {
log!(
"Retaining pending Relay {} until the Iota identity is available",
record.id
);
continue;
};
let Some(keyring) = self.keyring.read().await.as_ref().cloned() else {
log!(
"Retaining pending Relay {} until the Iota keyring is available",
record.id
);
continue;
};
let resolver_connection = self;
let verified = verify_relay_metadata(
&frame,
local_iota_id,
&keyring,
move |signer_id| async move {
resolver_connection
.resolve_relay_signing_keys(signer_id)
.await
},
)
.await;
let Ok(verified) = verified else {
log!("Deleting unverifiable pending Relay {}", record.id);
if let Err(error) = relay_queue::delete(record.id) {
log!("Pending Relay {} cleanup failed: {}", record.id, error);
}
continue;
};
let relay_identity = match (
i64::try_from(verified.context.signer_id),
i64::try_from(verified.context.final_recipient_id),
) {
(Ok(signer_id), Ok(destination_user_id)) => relay_queue::RelayIdentity {
signer_id,
destination_user_id,
message_id: verified.context.message_id,
},
_ => {
log!(
"Deleting pending Relay {} with an out-of-range identity",
record.id
);
if let Err(error) = relay_queue::delete(record.id) {
log!("Pending Relay {} cleanup failed: {}", record.id, error);
}
continue;
}
};
if let Err(error) = relay_queue::set_relay_identity(record.id, &relay_identity) {
log!(
"Pending Relay {} ownership backfill failed: {}",
record.id,
error
);
continue;
}
}
let Ok(forwarded) = forward_verified_relay(&frame, record.target) else {
log!(
"Retaining pending Relay {} with invalid route target",
@ -1450,22 +1756,63 @@ impl OmikronConnection {
match record.target {
RouteTarget::Iota(destination_iota) => {
match self
.await_response(&forwarded, Some(Duration::from_secs(20)))
.await_relay_response(&forwarded, Duration::from_secs(20))
.await
{
Ok(response) if response.is_type(CommunicationType::Success) => {
if let Err(error) =
relay_queue::acknowledge_iota(destination_iota, record.frame_id)
let accepted_at = response
.get_data(DataType::RelayAcceptedAt)
.as_number()
.and_then(|value| i64::try_from(value).ok());
let relay_id = response.get_data(DataType::RelayMessageId).as_str();
if let (Some(accepted_at), Some(message_id), Some(signer_id)) = (
accepted_at,
record.relay_message_id.as_deref(),
record.relay_signer_id,
) && relay_id == Some(message_id)
{
log!(
"Pending Relay {} acknowledgement could not clear the queue: {}",
record.id,
error
);
if let Err(error) =
iota_storage::util::downstream_relay::acknowledge_iota_delivery(
destination_iota,
record.frame_id,
signer_id,
message_id,
accepted_at,
)
{
log!(
"Pending Relay {} acknowledgement failed: {}",
record.id,
error
);
}
} else {
log!("Pending Relay {} returned malformed Success", record.id);
}
}
Ok(response) if !response.is_type(CommunicationType::ErrorInternal) => {
if let (Some(message_id), Some(signer_id)) =
(record.relay_message_id.as_deref(), record.relay_signer_id)
{
if let Err(error) =
iota_storage::util::downstream_relay::reject_iota_delivery(
destination_iota,
record.frame_id,
signer_id,
message_id,
"destination_rejected",
)
{
log!(
"Pending Relay {} rejection cleanup failed: {}",
record.id,
error
);
}
}
}
Ok(response) => log!(
"Pending Relay {} route returned {}",
"Pending Relay {} route returned retryable {}",
record.id,
response.get_type()
),
@ -1497,16 +1844,15 @@ impl OmikronConnection {
.as_number()
.and_then(|value| u64::try_from(value).ok())
{
match relay_queue::acknowledge(destination_id, frame_id) {
Ok(true) => {
if let Err(error) =
relay_replay::mark_delivered_for_frame(destination_id, frame_id)
{
log!("Relay delivery state update failed: {}", error);
}
let Ok(destination_id) = i64::try_from(destination_id) else {
log!("Relay destination ID exceeds storage range");
return;
};
match client_relay_delivery::acknowledge_client_delivery(destination_id, frame_id) {
Ok(client_relay_delivery::ClientRelayDeliveryResult::Acknowledged) => {
return;
}
Ok(false) => {}
Ok(client_relay_delivery::ClientRelayDeliveryResult::NotFound) => {}
Err(error) => log!("Relay delivery acknowledgement failed: {}", error),
}
}
@ -1587,6 +1933,18 @@ impl OmikronConnection {
dispatch!(SyncedSettingGet, handle_synced_setting_get);
dispatch!(SyncedSettingDelete, handle_synced_setting_delete);
dispatch!(SyncedSettingsList, handle_synced_settings_list);
dispatch!(UserBlobPut, handle_user_blob_put);
dispatch!(UserBlobGet, handle_user_blob_get);
dispatch!(UserBlobDelete, handle_user_blob_delete);
dispatch!(UserBlobList, handle_user_blob_list);
dispatch!(UserBlock, handle_user_block);
dispatch!(UserUnblock, handle_user_unblock);
dispatch!(BlockedUsersGet, handle_blocked_users_get);
dispatch!(ReceiptPolicyGet, handle_receipt_policy_get);
dispatch!(ReceiptPolicySet, handle_receipt_policy_set);
dispatch!(MessageStoragePolicyGet, handle_message_storage_policy_get);
dispatch!(MessageStoragePolicySet, handle_message_storage_policy_set);
dispatch!(UserBlockCheck, handle_user_block_check);
dispatch!(EraseHostedUserData, handle_erase_hosted_user_data);
}
@ -2232,6 +2590,90 @@ impl OmikronConnection {
.await;
}
async fn handle_user_blob_put(self: Arc<Self>, cv: &CommunicationValue) {
let mutation = message_handlers::handle_user_blob_put(cv);
let _ = self.send_message(&mutation.response).await;
if let Some(changed) = mutation.changed {
let _ = self.send_message(&changed).await;
}
}
async fn handle_user_blob_get(self: Arc<Self>, cv: &CommunicationValue) {
let _ = self
.send_message(&message_handlers::handle_user_blob_get(cv))
.await;
}
async fn handle_user_blob_delete(self: Arc<Self>, cv: &CommunicationValue) {
let mutation = message_handlers::handle_user_blob_delete(cv);
let _ = self.send_message(&mutation.response).await;
if let Some(changed) = mutation.changed {
let _ = self.send_message(&changed).await;
}
}
async fn handle_user_blob_list(self: Arc<Self>, cv: &CommunicationValue) {
let _ = self
.send_message(&message_handlers::handle_user_blob_list(cv))
.await;
}
async fn handle_user_block(self: Arc<Self>, cv: &CommunicationValue) {
let mutation = message_handlers::handle_user_block(cv);
let _ = self.send_message(&mutation.response).await;
if let Some(changed) = mutation.changed {
let _ = self.send_message(&changed).await;
}
}
async fn handle_user_unblock(self: Arc<Self>, cv: &CommunicationValue) {
let mutation = message_handlers::handle_user_unblock(cv);
let _ = self.send_message(&mutation.response).await;
if let Some(changed) = mutation.changed {
let _ = self.send_message(&changed).await;
}
}
async fn handle_blocked_users_get(self: Arc<Self>, cv: &CommunicationValue) {
let _ = self
.send_message(&message_handlers::handle_blocked_users_get(cv))
.await;
}
async fn handle_receipt_policy_get(self: Arc<Self>, cv: &CommunicationValue) {
let _ = self
.send_message(&message_handlers::handle_receipt_policy_get(cv))
.await;
}
async fn handle_receipt_policy_set(self: Arc<Self>, cv: &CommunicationValue) {
let mutation = message_handlers::handle_receipt_policy_set(cv);
let _ = self.send_message(&mutation.response).await;
if let Some(changed) = mutation.changed {
let _ = self.send_message(&changed).await;
}
}
async fn handle_message_storage_policy_get(self: Arc<Self>, cv: &CommunicationValue) {
let _ = self
.send_message(&message_handlers::handle_message_storage_policy_get(cv))
.await;
}
async fn handle_message_storage_policy_set(self: Arc<Self>, cv: &CommunicationValue) {
let mutation = message_handlers::handle_message_storage_policy_set(cv);
let _ = self.send_message(&mutation.response).await;
if let Some(changed) = mutation.changed {
let _ = self.send_message(&changed).await;
}
}
async fn handle_user_block_check(self: Arc<Self>, cv: &CommunicationValue) {
let _ = self
.send_message(&message_handlers::handle_user_block_check(cv))
.await;
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
@ -2365,6 +2807,40 @@ impl OmikronConnection {
}
}
async fn await_relay_response(
&self,
cv: &CommunicationValue,
timeout: Duration,
) -> Result<CommunicationValue, String> {
let (tx, rx) = oneshot::channel();
let msg_id = cv.require_id().map_err(|error| error.to_string())?;
WAITING_TASKS.insert(
msg_id,
WaitingTask {
task: Box::new(move |response| {
let _ = tx.send(response);
true
}),
inserted_at: Instant::now(),
},
);
if let Err(error) = self.send_message(cv).await {
WAITING_TASKS.remove(&msg_id);
return Err(format!("Relay send failed: {error}"));
}
match tokio::time::timeout(timeout, rx).await {
Ok(Ok(response)) => Ok(response),
Ok(Err(_)) => {
WAITING_TASKS.remove(&msg_id);
Err("Relay response channel closed".into())
}
Err(_) => {
WAITING_TASKS.remove(&msg_id);
Err("Relay response timed out".into())
}
}
}
pub async fn await_connection(&self, timeout_duration: Option<Duration>) -> Result<(), String> {
let mut rx = self.state_watch_tx.subscribe();
if rx.borrow().is_connected() {