[Upd] mtp update

This commit is contained in:
Alex Emmet 2026-07-20 15:28:15 +02:00
commit f82500ea7d
24 changed files with 2535 additions and 1800 deletions

View file

@ -3,8 +3,8 @@ use crate::util::db;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use iota_util::crypto_helper::{self, hex_hash, keyring_from_base64, public_key_bundle_to_base64};
use iota_util::file_util::{load_file, save_file};
use rusqlite::params;
use rand_core::{OsRng, RngCore};
use rusqlite::params;
pub fn add_user(user: UserProfile) {
if let Err(e) = db::with_db(|conn| {
@ -166,9 +166,8 @@ pub fn get_users() -> Vec<UserProfile> {
fn load_trusted_apps(user_id: i64) -> std::collections::HashMap<String, String> {
match db::with_db(|conn| {
let mut stmt = conn.prepare(
"SELECT app_id, app_secret FROM trusted_apps WHERE user_id = ?1",
)?;
let mut stmt =
conn.prepare("SELECT app_id, app_secret FROM trusted_apps WHERE user_id = ?1")?;
let rows = stmt.query_map(params![user_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})?;
@ -191,7 +190,10 @@ fn load_trusted_apps(user_id: i64) -> std::collections::HashMap<String, String>
pub fn remove_user(user_id: i64) {
if let Err(e) = db::with_db(|conn| {
conn.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?;
conn.execute(
"DELETE FROM trusted_apps WHERE user_id = ?1",
params![user_id],
)?;
conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?;
Ok(())
}) {

View file

@ -1,7 +1,7 @@
use crate::storage_error::StorageError;
use crate::util::db;
use iota_logger::log;
use rusqlite::params;
use crate::storage_error::StorageError;
#[derive(PartialEq, Debug, Clone)]
pub enum MessageState {
@ -53,7 +53,13 @@ pub struct StoredMessage {
pub message_state: String,
pub height: i64,
pub reply_to: Option<i64>,
pub reactions: Vec<String>,
pub reactions: Vec<StoredReaction>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoredReaction {
pub reaction: String,
pub user_id: i64,
}
/*
@ -66,6 +72,48 @@ pub fn edit_message(
message_time: i64,
editor_id: i64,
new_content: &str,
) -> Result<(), StorageError> {
update_message_content(
storage_owner,
external_user,
message_time,
editor_id,
new_content,
true,
)
}
/* Applies an edit received from the message sender to the recipient's copy. */
pub fn apply_remote_edit(
storage_owner: i64,
external_user: i64,
message_time: i64,
editor_id: i64,
new_content: &str,
) -> Result<(), StorageError> {
if editor_id != external_user {
return Err(StorageError::Other(
"Remote editor does not match chat partner".into(),
));
}
update_message_content(
storage_owner,
external_user,
message_time,
editor_id,
new_content,
false,
)
}
fn update_message_content(
storage_owner: i64,
external_user: i64,
message_time: i64,
editor_id: i64,
new_content: &str,
require_sent_by_self: bool,
) -> Result<(), StorageError> {
db::with_db(|conn| {
let msg = conn.query_row(
@ -86,11 +134,16 @@ pub fn edit_message(
)?;
let (msg_id, old_content, sent_by_self) = msg;
if sent_by_self != 1 {
if require_sent_by_self && sent_by_self != 1 {
return Err(StorageError::Other(
"Only the original sender can edit this message".into(),
));
}
if !require_sent_by_self && sent_by_self != 0 {
return Err(StorageError::Other(
"Remote edits may only update received messages".into(),
));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -118,7 +171,11 @@ pub fn edit_message(
})
}
pub fn hard_delete_message(storage_owner: i64, external_user: i64, message_time: i64) -> Result<(), StorageError> {
pub fn hard_delete_message(
storage_owner: i64,
external_user: i64,
message_time: i64,
) -> Result<(), StorageError> {
db::with_db(|conn| {
let msg_id: i64 = conn.query_row(
r#"
@ -130,18 +187,79 @@ pub fn hard_delete_message(storage_owner: i64, external_user: i64, message_time:
|row| row.get(0),
)?;
conn.execute("DELETE FROM message_edits WHERE message_id = ?1", params![msg_id])?;
conn.execute("DELETE FROM reactions WHERE message_id = ?1", params![msg_id])?;
conn.execute(
"DELETE FROM message_edits WHERE message_id = ?1",
params![msg_id],
)?;
conn.execute(
"DELETE FROM reactions WHERE message_id = ?1",
params![msg_id],
)?;
conn.execute("DELETE FROM messages WHERE id = ?1", params![msg_id])?;
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)
}
/* Flags the recipient's local copy after validating its sender, preserving its history. */
pub fn apply_remote_delete(
storage_owner: i64,
external_user: i64,
message_time: i64,
sender_id: i64,
) -> Result<(), StorageError> {
if sender_id != external_user {
return Err(StorageError::Other(
"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)
}
fn ensure_message_direction(
storage_owner: i64,
external_user: i64,
message_time: i64,
expected_sent_by_self: bool,
) -> Result<(), StorageError> {
db::with_db(|conn| {
let sent_by_self: i64 = conn.query_row(
r#"
SELECT sent_by_self 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),
)?;
if (sent_by_self != 0) != expected_sent_by_self {
return Err(StorageError::Other(
"Message sender is not authorized".into(),
));
}
Ok(())
})
}
/*
* Marks a message as deleted by the external user rather than removing the row,
* so the storage owner still sees a tombstone in the UI.
*/
pub fn flag_deleted_by_external(storage_owner: i64, external_user: i64, message_time: i64) -> Result<(), StorageError> {
pub fn flag_deleted_by_external(
storage_owner: i64,
external_user: i64,
message_time: i64,
) -> Result<(), StorageError> {
db::with_db(|conn| {
let affected = conn.execute(
r#"
@ -163,7 +281,11 @@ pub fn flag_deleted_by_external(storage_owner: i64, external_user: i64, message_
* the UI still shows the "edited" indicator. Only the own user should
* call this.
*/
pub fn delete_edit_history(storage_owner: i64, external_user: i64, message_time: i64) -> Result<(), StorageError> {
pub fn delete_edit_history(
storage_owner: i64,
external_user: i64,
message_time: i64,
) -> Result<(), StorageError> {
db::with_db(|conn| {
let msg_id: i64 = conn.query_row(
r#"
@ -175,7 +297,10 @@ pub fn delete_edit_history(storage_owner: i64, external_user: i64, message_time:
|row| row.get(0),
)?;
conn.execute("DELETE FROM message_edits WHERE message_id = ?1", params![msg_id])?;
conn.execute(
"DELETE FROM message_edits WHERE message_id = ?1",
params![msg_id],
)?;
Ok(())
})
}
@ -339,26 +464,39 @@ pub fn change_message_state(
.map_err(|e: StorageError| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
}
fn load_reactions(conn: &rusqlite::Connection, msg_ids: &[i64]) -> std::collections::HashMap<i64, Vec<String>> {
fn load_reactions(
conn: &rusqlite::Connection,
msg_ids: &[i64],
) -> std::collections::HashMap<i64, Vec<StoredReaction>> {
if msg_ids.is_empty() {
return std::collections::HashMap::new();
}
let placeholders: Vec<String> = msg_ids.iter().enumerate()
let placeholders: Vec<String> = msg_ids
.iter()
.enumerate()
.map(|(i, _)| format!("?{}", i + 1))
.collect();
let query = format!(
"SELECT message_id, reaction || ':' || COUNT(*) FROM reactions WHERE message_id IN ({}) GROUP BY message_id, reaction",
"SELECT message_id, reaction, user_id FROM reactions WHERE message_id IN ({}) ORDER BY created_at ASC, id ASC",
placeholders.join(", ")
);
let mut map: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
let mut map: std::collections::HashMap<i64, Vec<StoredReaction>> =
std::collections::HashMap::new();
if let Ok(mut stmt) = conn.prepare(&query) {
let params: Vec<&dyn rusqlite::types::ToSql> = msg_ids.iter()
let params: Vec<&dyn rusqlite::types::ToSql> = msg_ids
.iter()
.map(|id| id as &dyn rusqlite::types::ToSql)
.collect();
if let Ok(rows) = stmt.query_map(params.as_slice(), |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
Ok((
row.get::<_, i64>(0)?,
StoredReaction {
reaction: row.get(1)?,
user_id: row.get(2)?,
},
))
}) {
for row in rows.flatten() {
map.entry(row.0).or_default().push(row.1);

View file

@ -64,6 +64,30 @@ fn db_file_path(db_name: &str) -> String {
fn run_migrations(pool: &r2d2::Pool<SqliteManager>) -> Result<(), StorageError> {
let conn = pool.get().map_err(|e| StorageError::Pool(e.to_string()))?;
run_migrations_on_connection(&conn)
}
/*
* Older builds could apply a schema change without advancing user_version.
* Check each added column so those databases can resume upgrading.
*/
fn add_column_if_missing(
conn: &Connection,
column: &str,
definition: &str,
) -> Result<(), StorageError> {
let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
let exists = statement.exists([column])?;
if !exists {
conn.execute_batch(&format!("ALTER TABLE messages ADD COLUMN {definition};"))?;
}
Ok(())
}
fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
let current_version: i64 = conn
.pragma_query_value(None, "user_version", |r| r.get(0))
.unwrap_or(0);
@ -78,8 +102,7 @@ fn run_migrations(pool: &r2d2::Pool<SqliteManager>) -> Result<(), StorageError>
message_time INTEGER NOT NULL,
content TEXT NOT NULL,
sent_by_self INTEGER NOT NULL,
message_state TEXT NOT NULL,
height INTEGER NOT NULL DEFAULT 0
message_state TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_lookup
ON messages (storage_owner, external_user, message_time DESC);
@ -129,29 +152,28 @@ fn run_migrations(pool: &r2d2::Pool<SqliteManager>) -> Result<(), StorageError>
}
if current_version < 2 {
conn.execute_batch(
r#"
ALTER TABLE messages ADD COLUMN height INTEGER NOT NULL DEFAULT 0;
PRAGMA user_version = 2;
"#,
)?;
add_column_if_missing(conn, "height", "height INTEGER NOT NULL DEFAULT 0")?;
conn.execute_batch("PRAGMA user_version = 2;")?;
}
if current_version < 3 {
conn.execute_batch(
r#"
ALTER TABLE messages ADD COLUMN reply_to INTEGER;
PRAGMA user_version = 3;
"#,
)?;
add_column_if_missing(conn, "reply_to", "reply_to INTEGER")?;
conn.execute_batch("PRAGMA user_version = 3;")?;
}
if current_version < 4 {
add_column_if_missing(
conn,
"edited_count",
"edited_count INTEGER NOT NULL DEFAULT 0",
)?;
add_column_if_missing(
conn,
"deleted_by_external",
"deleted_by_external INTEGER NOT NULL DEFAULT 0",
)?;
conn.execute_batch(
r#"
ALTER TABLE messages ADD COLUMN edited_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE messages ADD COLUMN deleted_by_external INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS message_edits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id),
@ -179,6 +201,24 @@ fn run_migrations(pool: &r2d2::Pool<SqliteManager>) -> Result<(), StorageError>
)?;
}
if current_version < 5 {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS settings (
user_id INTEGER NOT NULL,
session_id INTEGER NOT NULL,
name TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (user_id, session_id, name)
);
CREATE INDEX IF NOT EXISTS idx_settings_lookup
ON settings (user_id, session_id, name);
PRAGMA user_version = 5;
"#,
)?;
}
Ok(())
}
@ -221,3 +261,40 @@ where
pub fn create_general_messages_db() -> Result<Arc<std::sync::Mutex<Connection>>, String> {
create_shared_connection(DB_NAME, "")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resumes_migration_when_height_exists_before_its_version() -> Result<(), StorageError> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
r#"
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
storage_owner INTEGER NOT NULL,
external_user INTEGER NOT NULL,
message_time INTEGER NOT NULL,
content TEXT NOT NULL,
sent_by_self INTEGER NOT NULL,
message_state TEXT NOT NULL
);
ALTER TABLE messages ADD COLUMN height INTEGER NOT NULL DEFAULT 0;
PRAGMA user_version = 1;
"#,
)?;
run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 5);
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")?;
assert!(statement.exists([column])?);
}
Ok(())
}
}

View file

@ -1,5 +1,5 @@
use crate::util::db;
use rusqlite::{params, OptionalExtension};
use rusqlite::{OptionalExtension, params};
use std::sync::{Arc, LazyLock, Mutex};
pub type StorageError = String;
@ -226,9 +226,7 @@ fn chat_secret_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoredChatS
})
}
fn pending_forward_from_row(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<PendingChatSecretForward> {
fn pending_forward_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PendingChatSecretForward> {
Ok(PendingChatSecretForward {
recipient_user_id: row.get(0)?,
chat_id: row.get(1)?,

View file

@ -4,3 +4,4 @@ pub mod communities_util;
pub mod config_util;
pub mod db;
pub mod e2ee_storage;
pub mod settings;

View file

@ -0,0 +1,163 @@
use crate::storage_error::StorageError;
use crate::util::db;
use iota_util::file_util::get_directory;
use rusqlite::{OptionalExtension, params};
use std::fs;
use std::path::Path;
pub const GLOBAL_SESSION_ID: i64 = 0;
const GLOBAL_SETTINGS_NAME: &str = "__global__";
pub fn save(user_id: i64, session_id: i64, name: &str, payload: &str) -> Result<(), StorageError> {
db::with_db(|conn| {
conn.execute(
"INSERT INTO settings (user_id, session_id, name, payload) VALUES (?1, ?2, ?3, ?4)\n ON CONFLICT(user_id, session_id, name) DO UPDATE SET payload = excluded.payload",
params![user_id, session_id, name, payload],
)?;
Ok(())
})
}
pub fn load(user_id: i64, session_id: i64, name: &str) -> Result<Option<String>, StorageError> {
db::with_db(|conn| {
conn.query_row(
"SELECT payload FROM settings WHERE user_id = ?1 AND session_id = ?2 AND name = ?3",
params![user_id, session_id, name],
|row| row.get(0),
)
.optional()
.map_err(StorageError::from)
})
}
pub fn list(user_id: i64, session_id: i64) -> Result<Vec<String>, StorageError> {
db::with_db(|conn| {
let mut statement = conn.prepare(
"SELECT name FROM settings WHERE user_id = ?1 AND session_id = ?2 ORDER BY name",
)?;
let rows = statement.query_map(params![user_id, session_id], |row| row.get(0))?;
rows.collect::<Result<Vec<String>, _>>()
.map_err(StorageError::from)
})
}
pub fn save_global(user_id: i64, payload: &str) -> Result<(), StorageError> {
save(user_id, GLOBAL_SESSION_ID, GLOBAL_SETTINGS_NAME, payload)
}
pub fn load_global(user_id: i64) -> Result<Option<String>, StorageError> {
load(user_id, GLOBAL_SESSION_ID, GLOBAL_SETTINGS_NAME)
}
pub fn migrate_legacy_files() -> Result<(), StorageError> {
let users_dir = Path::new(&get_directory()).join("users");
let Ok(users) = fs::read_dir(users_dir) else {
return Ok(());
};
for user_entry in users {
let user_entry = user_entry?;
let Ok(user_id) = user_entry.file_name().to_string_lossy().parse::<i64>() else {
continue;
};
let user_dir = user_entry.path();
migrate_file_if_missing(
user_id,
GLOBAL_SESSION_ID,
GLOBAL_SETTINGS_NAME,
&user_dir.join("global.settings"),
)?;
let settings_dir = user_dir.join("settings");
let Ok(settings_entries) = fs::read_dir(settings_dir) else {
continue;
};
for settings_entry in settings_entries {
let settings_entry = settings_entry?;
let path = settings_entry.path();
if path.is_file() {
if let Some(name) = setting_name(&path) {
migrate_file_if_missing(user_id, GLOBAL_SESSION_ID, &name, &path)?;
}
continue;
}
let Ok(session_id) = settings_entry.file_name().to_string_lossy().parse::<i64>() else {
continue;
};
let Ok(device_settings) = fs::read_dir(path) else {
continue;
};
for setting_entry in device_settings {
let setting_entry = setting_entry?;
let path = setting_entry.path();
if let Some(name) = setting_name(&path) {
migrate_file_if_missing(user_id, session_id, &name, &path)?;
}
}
}
}
Ok(())
}
fn setting_name(path: &Path) -> Option<String> {
(path.extension()?.to_str()? == "settings").then(|| {
path.file_stem()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_string()
})
}
fn migrate_file_if_missing(
user_id: i64,
session_id: i64,
name: &str,
path: &Path,
) -> Result<(), StorageError> {
if !path.is_file() || load(user_id, session_id, name)?.is_some() {
return Ok(());
}
let payload = fs::read_to_string(path)?;
save(user_id, session_id, name, &payload)
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
#[test]
fn settings_schema_supports_user_and_session_keys() -> Result<(), StorageError> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
"CREATE TABLE settings (
user_id INTEGER NOT NULL,
session_id INTEGER NOT NULL,
name TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (user_id, session_id, name)
);",
)?;
conn.execute(
"INSERT INTO settings VALUES (?1, ?2, ?3, ?4)",
params![7, 11, "theme", "dark"],
)?;
conn.execute(
"INSERT INTO settings VALUES (?1, ?2, ?3, ?4)",
params![7, 12, "theme", "light"],
)?;
let payload: String = conn.query_row(
"SELECT payload FROM settings WHERE user_id = 7 AND session_id = 11 AND name = 'theme'",
[],
|row| row.get(0),
)?;
assert_eq!(payload, "dark");
Ok(())
}
}