use std::collections::HashMap;
use crate::types::Attachment;
const SELECT_COLS: &str = "event_id, att_index, hash, key, nonce, extension, name, url, \
path, size, img_meta, downloaded, webxdc_topic, group_id, original_hash, fallback_urls";
fn row_to_attachment(row: &rusqlite::Row) -> rusqlite::Result<(String, Attachment)> {
let event_id: String = row.get(0)?;
let img_meta_json: Option<String> = row.get(10)?;
let att = Attachment {
id: row.get(2)?,
key: row.get(3)?,
nonce: row.get(4)?,
extension: row.get(5)?,
name: row.get(6)?,
url: row.get(7)?,
path: row.get(8)?,
size: row.get::<_, i64>(9)? as u64,
img_meta: img_meta_json.and_then(|j| serde_json::from_str(&j).ok()),
downloading: false,
downloaded: row.get::<_, i64>(11)? != 0,
webxdc_topic: row.get(12)?,
group_id: row.get(13)?,
original_hash: row.get(14)?,
fallback_urls: row
.get::<_, String>(15)?
.split_whitespace()
.map(|s| s.to_string())
.collect(),
};
Ok((event_id, att))
}
pub fn insert_attachment_rows(conn: &rusqlite::Connection, event_id: &str, attachments: &[Attachment]) -> Result<(), String> {
if attachments.is_empty() {
return Ok(());
}
let mut stmt = conn.prepare_cached(
"INSERT INTO attachments (event_id, att_index, hash, key, nonce, extension, name, url, \
path, size, img_meta, downloaded, webxdc_topic, group_id, original_hash, fallback_urls) \
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16) \
ON CONFLICT(event_id, att_index) DO UPDATE SET \
key=excluded.key, nonce=excluded.nonce, extension=excluded.extension, \
name=excluded.name, url=excluded.url, size=excluded.size, img_meta=excluded.img_meta, \
webxdc_topic=excluded.webxdc_topic, group_id=excluded.group_id, \
original_hash=excluded.original_hash, \
fallback_urls=CASE WHEN excluded.fallback_urls='' THEN fallback_urls ELSE excluded.fallback_urls END, \
downloaded=MAX(downloaded, excluded.downloaded), \
hash=CASE WHEN excluded.downloaded=1 THEN excluded.hash ELSE hash END, \
path=CASE WHEN excluded.downloaded=1 THEN excluded.path ELSE path END",
).map_err(|e| format!("prepare insert attachment: {e}"))?;
for (i, a) in attachments.iter().enumerate() {
let img_meta_json = a.img_meta.as_ref().and_then(|m| serde_json::to_string(m).ok());
stmt.execute(
rusqlite::params![
event_id, i as i64, a.id, a.key, a.nonce, a.extension, a.name, a.url,
a.path, a.size as i64, img_meta_json, a.downloaded as i64,
a.webxdc_topic, a.group_id, a.original_hash,
a.fallback_urls.join(" "),
],
).map_err(|e| format!("insert attachment: {e}"))?;
}
Ok(())
}
pub fn get_attachments_for_events(event_ids: &[String]) -> Result<HashMap<String, Vec<Attachment>>, String> {
let mut out: HashMap<String, Vec<Attachment>> = HashMap::new();
if event_ids.is_empty() {
return Ok(out);
}
let conn = super::get_db_connection_guard_static()?;
let sql = format!(
"SELECT {SELECT_COLS} FROM attachments WHERE event_id IN (SELECT value FROM json_each(?1)) ORDER BY event_id, att_index"
);
let mut stmt = conn.prepare_cached(&sql).map_err(|e| format!("prepare get_attachments: {e}"))?;
let rows = stmt.query_map([super::events::id_list_param(event_ids)], row_to_attachment)
.map_err(|e| format!("query get_attachments: {e}"))?;
for r in rows.flatten() {
out.entry(r.0).or_default().push(r.1);
}
Ok(out)
}
pub fn get_attachments_for_event(event_id: &str) -> Result<Vec<Attachment>, String> {
let map = get_attachments_for_events(std::slice::from_ref(&event_id.to_string()))?;
Ok(map.into_values().next().unwrap_or_default())
}
pub fn find_by_webxdc_topic_in(chat_identifier: &str, topic: &str) -> Result<Option<(String, Attachment)>, String> {
let Ok(chat) = super::id_cache::get_chat_id_by_identifier(chat_identifier) else { return Ok(None) };
let conn = super::get_db_connection_guard_static()?;
let cols = SELECT_COLS.split(", ").map(|c| format!("a.{}", c.trim())).collect::<Vec<_>>().join(", ");
let mut stmt = conn
.prepare(&format!(
"SELECT {cols} FROM attachments a JOIN events e ON e.id = a.event_id \
WHERE a.webxdc_topic = ?1 AND e.chat_id = ?2 ORDER BY e.created_at ASC LIMIT 1"
))
.map_err(|e| format!("prepare topic lookup: {e}"))?;
let mut rows = stmt.query(rusqlite::params![topic, chat]).map_err(|e| format!("topic lookup: {e}"))?;
match rows.next().map_err(|e| format!("topic lookup: {e}"))? {
Some(row) => row_to_attachment(row).map(Some).map_err(|e| format!("topic row: {e}")),
None => Ok(None),
}
}
pub fn events_with_attachment_hash(hash: &str) -> Result<Vec<String>, String> {
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare(
"SELECT a.event_id FROM attachments a JOIN events e ON e.id = a.event_id \
WHERE a.hash = ?1 ORDER BY e.created_at DESC, e.received_at DESC, e.rowid DESC",
)
.map_err(|e| format!("prepare events_with_attachment_hash: {e}"))?;
let rows = stmt
.query_map(rusqlite::params![hash], |r| r.get::<_, String>(0))
.map_err(|e| format!("query events_with_attachment_hash: {e}"))?;
Ok(rows.flatten().collect())
}
pub fn set_attachment_downloaded(event_id: &str, hash: &str, downloaded: bool, path: &str) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
conn.execute(
"UPDATE attachments SET downloaded=?1, path=?2 WHERE event_id=?3 AND hash=?4",
rusqlite::params![downloaded as i64, path, event_id, hash],
).map_err(|e| format!("set_attachment_downloaded: {e}"))?;
Ok(())
}
pub fn backfill_downloaded_by_hash(hash: &str, path: &str, exclude_event_id: &str) -> Result<Vec<String>, String> {
let conn = super::get_write_connection_guard_static()?;
let affected: Vec<String> = {
let mut stmt = conn.prepare(
"SELECT DISTINCT event_id FROM attachments WHERE hash=?1 AND event_id!=?2 AND downloaded=0"
).map_err(|e| format!("prepare backfill_by_hash: {e}"))?;
let rows = stmt.query_map(rusqlite::params![hash, exclude_event_id], |r| r.get::<_, String>(0))
.map_err(|e| format!("query backfill_by_hash: {e}"))?;
rows.flatten().collect()
};
conn.execute(
"UPDATE attachments SET downloaded=1, path=?1 WHERE hash=?2 AND event_id!=?3 AND downloaded=0",
rusqlite::params![path, hash, exclude_event_id],
).map_err(|e| format!("backfill_by_hash update: {e}"))?;
Ok(affected)
}
pub fn downloaded_attachment_paths() -> Result<Vec<(String, String, String)>, String> {
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn.prepare(
"SELECT event_id, hash, path FROM attachments WHERE downloaded=1 AND path!=''"
).map_err(|e| format!("prepare downloaded_paths: {e}"))?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))
.map_err(|e| format!("query downloaded_paths: {e}"))?;
Ok(rows.flatten().collect())
}
pub fn clear_attachment_download(event_id: &str, hash: &str) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
conn.execute(
"UPDATE attachments SET downloaded=0, path='' WHERE event_id=?1 AND hash=?2",
rusqlite::params![event_id, hash],
).map_err(|e| format!("clear_attachment_download: {e}"))?;
Ok(())
}
pub fn rewrite_downloaded_paths(old_prefixes: &[String], new_dir: &std::path::Path) -> Result<Vec<String>, String> {
let conn = super::get_write_connection_guard_static()?;
let rows: Vec<(i64, String, String)> = {
let mut stmt = conn.prepare("SELECT id, event_id, path FROM attachments WHERE downloaded=1 AND path!=''")
.map_err(|e| format!("prepare rewrite_paths: {e}"))?;
let mapped = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))
.map_err(|e| format!("query rewrite_paths: {e}"))?;
mapped.flatten().collect()
};
let mut affected = Vec::new();
for (rowid, event_id, path) in rows {
if !old_prefixes.iter().any(|p| path.starts_with(p.as_str())) {
continue;
}
let Some(name) = std::path::Path::new(&path).file_name() else { continue };
let new_path = new_dir.join(name);
let res = if new_path.exists() {
conn.execute("UPDATE attachments SET path=?1 WHERE id=?2",
rusqlite::params![new_path.to_string_lossy().to_string(), rowid])
} else {
conn.execute("UPDATE attachments SET downloaded=0, path='' WHERE id=?1", rusqlite::params![rowid])
};
if res.is_ok() {
affected.push(event_id);
}
}
Ok(affected)
}
#[derive(Debug, Clone)]
pub struct ReusableUpload {
pub url: String,
pub key: String,
pub nonce: String,
pub size: u64,
pub mine: bool,
}
pub async fn verify_local_copy(att: &crate::types::Attachment) -> Option<String> {
let expected = att.id.clone();
let mut candidates: Vec<std::path::PathBuf> = downloaded_paths_by_hash(&expected)
.unwrap_or_default()
.into_iter()
.map(Into::into)
.collect();
if !att.name.is_empty() {
candidates.push(crate::db::get_download_dir().join(&att.name));
}
let size = att.size;
#[cfg(target_arch = "wasm32")]
{
for p in candidates {
let Some(len) = crate::webfiles::size(&p).await else { continue };
if size != 0 && len != size && len + 16 != size {
continue;
}
if let Ok(bytes) = crate::webfiles::read(&p).await {
if crate::crypto::sha256_hex(&bytes) == expected {
return Some(p.to_string_lossy().to_string());
}
}
}
None
}
#[cfg(not(target_arch = "wasm32"))]
{
candidates.retain(|p| match std::fs::metadata(p) {
Ok(m) => size == 0 || m.len() == size || m.len() + 16 == size,
Err(_) => false,
});
if candidates.is_empty() {
return None;
}
crate::rt::spawn_blocking(move || {
candidates.into_iter().find_map(|p| {
let bytes = std::fs::read(&p).ok()?;
(crate::crypto::sha256_hex(&bytes) == expected)
.then(|| p.to_string_lossy().to_string())
})
})
.await
.ok()
.flatten()
}
}
pub async fn verify_message_attachments(msg: &mut crate::types::Message, chat_hint: Option<&str>) {
let mut converted: Vec<(String, String)> = Vec::new();
for att in &mut msg.attachments {
if !att.downloaded {
if let Some(path) = verify_local_copy(att).await {
att.downloaded = true;
att.path = path.clone();
converted.push((att.id.clone(), path));
}
}
}
if let Some(chat) = chat_hint {
if !converted.is_empty() {
let mut state = crate::state::STATE.lock().await;
for (att_id, path) in converted {
state.update_attachment(chat, &msg.id, &att_id, |a| {
a.set_downloaded(true);
a.path = path.into_boxed_str();
});
}
}
}
}
pub fn downloaded_paths_by_hash(hash: &str) -> Result<Vec<String>, String> {
let conn = crate::db::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare("SELECT DISTINCT path FROM attachments WHERE hash = ?1 AND downloaded = 1 AND path != ''")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([hash], |r| r.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok(rows)
}
pub fn find_reusable_by_hash(hash: &str) -> Result<Option<ReusableUpload>, String> {
if hash.is_empty() {
return Ok(None);
}
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare(
"SELECT a.url, a.key, a.nonce, a.size, COALESCE(e.mine, 0)
FROM attachments a JOIN events e ON e.id = a.event_id
WHERE a.hash = ?1 AND a.url <> '' AND a.key <> '' AND a.nonce <> '' AND a.downloaded = 1
ORDER BY COALESCE(e.mine, 0) DESC, a.id DESC LIMIT 1",
)
.map_err(|e| format!("find_reusable_by_hash prepare: {e}"))?;
let row = stmt
.query_row(rusqlite::params![hash], |r| {
Ok(ReusableUpload {
url: r.get(0)?,
key: r.get(1)?,
nonce: r.get(2)?,
size: r.get::<_, i64>(3)? as u64,
mine: r.get::<_, i64>(4)? != 0,
})
})
.ok();
Ok(row)
}
pub fn urls_unreferenced_elsewhere(urls: &[String], excluding_event_id: &str) -> Result<Vec<String>, String> {
if urls.is_empty() {
return Ok(Vec::new());
}
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare("SELECT 1 FROM attachments WHERE url = ?1 AND event_id <> ?2 LIMIT 1")
.map_err(|e| format!("urls_unreferenced prepare: {e}"))?;
let mut out = Vec::with_capacity(urls.len());
for url in urls {
let referenced = stmt.exists(rusqlite::params![url, excluding_event_id])
.map_err(|e| format!("urls_unreferenced query: {e}"))?;
if !referenced {
out.push(url.clone());
}
}
Ok(out)
}
pub fn hash_referenced_elsewhere(hash: &str, excluding_event_id: &str) -> Result<bool, String> {
if hash.is_empty() {
return Ok(false);
}
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare("SELECT 1 FROM attachments WHERE hash = ?1 AND event_id <> ?2 LIMIT 1")
.map_err(|e| format!("hash_referenced prepare: {e}"))?;
stmt.exists(rusqlite::params![hash, excluding_event_id])
.map_err(|e| format!("hash_referenced query: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Attachment, Message};
static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(74000);
fn make_test_npub(n: u32) -> String {
const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
let mut payload = vec![b'q'; 58];
let mut x = n as u64;
let mut i = 58;
while x > 0 && i > 0 {
i -= 1;
payload[i] = BECH32[(x as usize) % 32];
x /= 32;
}
format!("npub1{}", std::str::from_utf8(&payload).unwrap())
}
fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
crate::db::close_database();
crate::db::clear_id_caches();
let tmp = tempfile::tempdir().unwrap();
let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let account = make_test_npub(n);
std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
crate::db::set_current_account(account.clone()).unwrap();
crate::db::init_database(&account).unwrap();
(tmp, guard)
}
fn att(hash: &str, url: &str, keyed: bool, downloaded: bool) -> Attachment {
Attachment {
id: hash.to_string(),
key: if keyed { "k".into() } else { String::new() },
nonce: if keyed { "n".into() } else { String::new() },
url: url.to_string(),
size: 42,
downloaded,
..Default::default()
}
}
async fn save(chat: &str, event_id: &str, mine: bool, a: Attachment) {
let msg = Message {
id: event_id.to_string(),
content: String::new(),
mine,
at: 1_000_000,
attachments: vec![a],
..Default::default()
};
crate::db::events::save_message(chat, &msg).await.unwrap();
}
#[tokio::test]
async fn a_topic_resolves_to_its_app_message_only_within_its_chat() {
let (_tmp, _guard) = init_test_db();
let mut game = att("HG", "https://b.example/g", true, true);
game.extension = "xdc".into();
game.webxdc_topic = Some("TOPICA".into());
save("npub1chatq", "evt_game", false, game).await;
save("npub1chatq", "evt_photo", false, att("HP", "https://b.example/p", true, true)).await;
let (event_id, found) = find_by_webxdc_topic_in("npub1chatq", "TOPICA").unwrap().expect("the topic's message");
assert_eq!(event_id, "evt_game");
assert_eq!(found.id, "HG");
assert!(find_by_webxdc_topic_in("npub1chatq", "TOPICB").unwrap().is_none());
let mut copy = att("HG", "https://b.example/g2", true, true);
copy.extension = "xdc".into();
copy.webxdc_topic = Some("TOPICA".into());
save("npub1stranger", "evt_copy", false, copy).await;
assert_eq!(find_by_webxdc_topic_in("npub1chatq", "TOPICA").unwrap().unwrap().0, "evt_game");
assert_eq!(find_by_webxdc_topic_in("npub1stranger", "TOPICA").unwrap().unwrap().0, "evt_copy");
assert!(find_by_webxdc_topic_in("npub1nobody", "TOPICA").unwrap().is_none());
}
#[tokio::test]
async fn the_ledger_finds_a_prior_upload_and_prefers_our_own() {
let (_tmp, _guard) = init_test_db();
save("chat_a", "evt_foreign", false, att("H1", "https://b.example/foreign", true, true)).await;
save("chat_b", "evt_mine", true, att("H1", "https://b.example/mine", true, true)).await;
save("chat_c", "evt_unverified", false, att("H2", "https://b.example/x", true, false)).await;
save("chat_d", "evt_keyless", true, att("H3", "https://b.example/y", false, true)).await;
let hit = find_reusable_by_hash("H1").unwrap().expect("H1 is reusable");
assert_eq!(hit.url, "https://b.example/mine", "own upload preferred");
assert!(hit.mine);
assert_eq!(hit.key, "k");
assert_eq!(hit.size, 42);
assert!(find_reusable_by_hash("H2").unwrap().is_none(), "unverified rows never qualify");
assert!(find_reusable_by_hash("H3").unwrap().is_none(), "keyless rows never qualify");
assert!(find_reusable_by_hash("H9").unwrap().is_none());
}
#[tokio::test]
async fn the_ledger_knows_where_the_bytes_already_landed() {
let (_tmp, _guard) = init_test_db();
let mut on_disk = att("HP", "https://b.example/p", true, true);
on_disk.path = "/dl/counter-strike-1.xdc".to_string();
save("chat_a", "evt_disk", true, on_disk).await;
let mut ghost = att("HP", "https://b.example/p2", true, false);
ghost.path = "/dl/never-finished.xdc".to_string();
save("chat_b", "evt_ghost", false, ghost).await;
save("chat_c", "evt_pathless", true, att("HP", "https://b.example/p3", true, true)).await;
assert_eq!(
downloaded_paths_by_hash("HP").unwrap(),
vec!["/dl/counter-strike-1.xdc".to_string()],
"only the downloaded row with a real path answers"
);
assert!(downloaded_paths_by_hash("H9").unwrap().is_empty());
}
#[tokio::test]
async fn a_claim_verifies_against_real_bytes_or_not_at_all() {
let (_tmp, _guard) = init_test_db();
let dir = tempfile::tempdir().unwrap();
let good = dir.path().join("counter-strike.xdc");
std::fs::write(&good, b"the actual game bytes").unwrap();
let real_hash = crate::crypto::sha256_hex(b"the actual game bytes");
let mut row = att(&real_hash, "https://b.example/cs", true, true);
row.path = good.to_string_lossy().to_string();
save("chat_a", "evt_cs", true, row).await;
let claim = |hash: &str, size: u64| crate::types::Attachment {
id: hash.to_string(),
size,
..Default::default()
};
let plain_len = b"the actual game bytes".len() as u64;
assert!(verify_local_copy(&claim(&real_hash, plain_len)).await.is_some());
assert!(verify_local_copy(&claim(&real_hash, plain_len + 16)).await.is_some());
assert!(verify_local_copy(&claim(&real_hash, 0)).await.is_some(), "sizeless claims still verify by content");
assert!(verify_local_copy(&claim(&real_hash, plain_len + 1)).await.is_none());
std::fs::write(&good, b"not the game").unwrap();
assert!(verify_local_copy(&claim(&real_hash, plain_len)).await.is_none());
}
#[tokio::test]
async fn a_shared_blob_survives_every_delete_but_the_last() {
let (_tmp, _guard) = init_test_db();
let url = "https://b.example/shared".to_string();
save("chat_a", "evt_first", true, att("HH", &url, true, true)).await;
save("chat_b", "evt_second", true, att("HH", &url, true, true)).await;
assert!(urls_unreferenced_elsewhere(&[url.clone()], "evt_first").unwrap().is_empty());
assert!(urls_unreferenced_elsewhere(&[url.clone()], "evt_second").unwrap().is_empty());
assert!(hash_referenced_elsewhere("HH", "evt_first").unwrap());
crate::db::events::delete_event("evt_second").await.unwrap();
assert_eq!(urls_unreferenced_elsewhere(&[url.clone()], "evt_first").unwrap(), vec![url]);
assert!(!hash_referenced_elsewhere("HH", "evt_first").unwrap());
}
}