Skip to main content

vector_core/db/
attachments.rs

1//! Attachments table operations.
2//!
3//! Attachments are normalized into one row per attachment (see migration 74), keyed to their event
4//! (`ON DELETE CASCADE`) and indexed by content hash. This module is the single source of truth for
5//! attachment persistence; the legacy `["attachments", …]` tag in `events.tags` is left in place on
6//! pre-migration events as an untouched safety net but is never read.
7
8use std::collections::HashMap;
9
10use crate::types::Attachment;
11
12const SELECT_COLS: &str = "event_id, att_index, hash, key, nonce, extension, name, url, \
13    path, size, img_meta, downloaded, webxdc_topic, group_id, original_hash, fallback_urls";
14
15/// Rebuild `(event_id, Attachment)` from a row selecting `SELECT_COLS`. `downloading` is transient
16/// runtime state and is never persisted (always false on load).
17fn row_to_attachment(row: &rusqlite::Row) -> rusqlite::Result<(String, Attachment)> {
18    let event_id: String = row.get(0)?;
19    let img_meta_json: Option<String> = row.get(10)?;
20    let att = Attachment {
21        id: row.get(2)?,
22        key: row.get(3)?,
23        nonce: row.get(4)?,
24        extension: row.get(5)?,
25        name: row.get(6)?,
26        url: row.get(7)?,
27        path: row.get(8)?,
28        size: row.get::<_, i64>(9)? as u64,
29        img_meta: img_meta_json.and_then(|j| serde_json::from_str(&j).ok()),
30        downloading: false,
31        downloaded: row.get::<_, i64>(11)? != 0,
32        webxdc_topic: row.get(12)?,
33        group_id: row.get(13)?,
34        original_hash: row.get(14)?,
35        fallback_urls: row
36            .get::<_, String>(15)?
37            .split_whitespace()
38            .map(|s| s.to_string())
39            .collect(),
40    };
41    Ok((event_id, att))
42}
43
44/// Upsert a message's attachment rows onto the given connection or transaction, so `save_message`
45/// can commit them ATOMICALLY with the event row (an event + no attachments would render as a broken
46/// file message with no fallback). Upserts on `(event_id, att_index)`. The mutable local state is
47/// handled so a re-save never regresses a completed download: `downloaded` is MONOTONIC
48/// (`MAX(existing, incoming)`), and `hash`/`path` only take the incoming values when the incoming
49/// carries a completed download (`downloaded=1`) — the nonce→content-hash rewrite the download path
50/// performs. So a relay re-delivery (downloaded=0) preserves the downloaded file, its content-hash
51/// key, and its path; a completed download persists all three in one pass. Explicit un-download goes
52/// through `clear_attachment_download`, never here.
53pub fn insert_attachment_rows(conn: &rusqlite::Connection, event_id: &str, attachments: &[Attachment]) -> Result<(), String> {
54    if attachments.is_empty() {
55        return Ok(());
56    }
57    // prepare_cached: the statement survives on the connection across calls (and transactions),
58    // so bulk-sync batches don't re-parse the SQL per message.
59    let mut stmt = conn.prepare_cached(
60        "INSERT INTO attachments (event_id, att_index, hash, key, nonce, extension, name, url, \
61         path, size, img_meta, downloaded, webxdc_topic, group_id, original_hash, fallback_urls) \
62         VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16) \
63         ON CONFLICT(event_id, att_index) DO UPDATE SET \
64            key=excluded.key, nonce=excluded.nonce, extension=excluded.extension, \
65            name=excluded.name, url=excluded.url, size=excluded.size, img_meta=excluded.img_meta, \
66            webxdc_topic=excluded.webxdc_topic, group_id=excluded.group_id, \
67            original_hash=excluded.original_hash, \
68            fallback_urls=CASE WHEN excluded.fallback_urls='' THEN fallback_urls ELSE excluded.fallback_urls END, \
69            downloaded=MAX(downloaded, excluded.downloaded), \
70            hash=CASE WHEN excluded.downloaded=1 THEN excluded.hash ELSE hash END, \
71            path=CASE WHEN excluded.downloaded=1 THEN excluded.path ELSE path END",
72    ).map_err(|e| format!("prepare insert attachment: {e}"))?;
73    for (i, a) in attachments.iter().enumerate() {
74        let img_meta_json = a.img_meta.as_ref().and_then(|m| serde_json::to_string(m).ok());
75        stmt.execute(
76            rusqlite::params![
77                event_id, i as i64, a.id, a.key, a.nonce, a.extension, a.name, a.url,
78                a.path, a.size as i64, img_meta_json, a.downloaded as i64,
79                a.webxdc_topic, a.group_id, a.original_hash,
80                a.fallback_urls.join(" "),
81            ],
82        ).map_err(|e| format!("insert attachment: {e}"))?;
83    }
84    Ok(())
85}
86
87/// Attachments for a set of events, `event_id → Vec<Attachment>` ordered by `att_index`. Batched
88/// (one `IN (…)` query) for a message window, mirroring the reactions/edits loaders.
89pub fn get_attachments_for_events(event_ids: &[String]) -> Result<HashMap<String, Vec<Attachment>>, String> {
90    let mut out: HashMap<String, Vec<Attachment>> = HashMap::new();
91    if event_ids.is_empty() {
92        return Ok(out);
93    }
94    let conn = super::get_db_connection_guard_static()?;
95    let placeholders = event_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
96    let sql = format!(
97        "SELECT {SELECT_COLS} FROM attachments WHERE event_id IN ({placeholders}) ORDER BY event_id, att_index"
98    );
99    let mut stmt = conn.prepare(&sql).map_err(|e| format!("prepare get_attachments: {e}"))?;
100    let params = rusqlite::params_from_iter(event_ids.iter());
101    let rows = stmt.query_map(params, row_to_attachment)
102        .map_err(|e| format!("query get_attachments: {e}"))?;
103    for r in rows.flatten() {
104        out.entry(r.0).or_default().push(r.1);
105    }
106    Ok(out)
107}
108
109/// Attachments for a single event, ordered by `att_index`.
110pub fn get_attachments_for_event(event_id: &str) -> Result<Vec<Attachment>, String> {
111    let map = get_attachments_for_events(std::slice::from_ref(&event_id.to_string()))?;
112    Ok(map.into_values().next().unwrap_or_default())
113}
114
115/// Flip one attachment's downloaded state — a single-row UPDATE keyed by (event_id, content hash),
116/// replacing the old read-modify-write of the whole tags blob.
117pub fn set_attachment_downloaded(event_id: &str, hash: &str, downloaded: bool, path: &str) -> Result<(), String> {
118    let conn = super::get_write_connection_guard_static()?;
119    conn.execute(
120        "UPDATE attachments SET downloaded=?1, path=?2 WHERE event_id=?3 AND hash=?4",
121        rusqlite::params![downloaded as i64, path, event_id, hash],
122    ).map_err(|e| format!("set_attachment_downloaded: {e}"))?;
123    Ok(())
124}
125
126/// Mark every OTHER attachment sharing this content hash as downloaded to the same path — the
127/// download-sharing dedup, now an indexed `WHERE hash = ?` instead of a `LIKE '%hash%'` table scan.
128/// Returns the affected event ids so the caller can reconcile in-memory STATE.
129pub fn backfill_downloaded_by_hash(hash: &str, path: &str, exclude_event_id: &str) -> Result<Vec<String>, String> {
130    let conn = super::get_write_connection_guard_static()?;
131    let affected: Vec<String> = {
132        let mut stmt = conn.prepare(
133            "SELECT DISTINCT event_id FROM attachments WHERE hash=?1 AND event_id!=?2 AND downloaded=0"
134        ).map_err(|e| format!("prepare backfill_by_hash: {e}"))?;
135        let rows = stmt.query_map(rusqlite::params![hash, exclude_event_id], |r| r.get::<_, String>(0))
136            .map_err(|e| format!("query backfill_by_hash: {e}"))?;
137        rows.flatten().collect()
138    };
139    conn.execute(
140        "UPDATE attachments SET downloaded=1, path=?1 WHERE hash=?2 AND event_id!=?3 AND downloaded=0",
141        rusqlite::params![path, hash, exclude_event_id],
142    ).map_err(|e| format!("backfill_by_hash update: {e}"))?;
143    Ok(affected)
144}
145
146/// A downloaded attachment's on-disk path, for the integrity sweep. Returns (event_id, hash, path)
147/// for every attachment claiming `downloaded=1` with a non-empty path — an indexed read, no per-event
148/// JSON parse.
149pub fn downloaded_attachment_paths() -> Result<Vec<(String, String, String)>, String> {
150    let conn = super::get_db_connection_guard_static()?;
151    let mut stmt = conn.prepare(
152        "SELECT event_id, hash, path FROM attachments WHERE downloaded=1 AND path!=''"
153    ).map_err(|e| format!("prepare downloaded_paths: {e}"))?;
154    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))
155        .map_err(|e| format!("query downloaded_paths: {e}"))?;
156    Ok(rows.flatten().collect())
157}
158
159/// Mark an attachment not-downloaded (its file went missing). Clears the path.
160pub fn clear_attachment_download(event_id: &str, hash: &str) -> Result<(), String> {
161    let conn = super::get_write_connection_guard_static()?;
162    conn.execute(
163        "UPDATE attachments SET downloaded=0, path='' WHERE event_id=?1 AND hash=?2",
164        rusqlite::params![event_id, hash],
165    ).map_err(|e| format!("clear_attachment_download: {e}"))?;
166    Ok(())
167}
168
169/// Repoint downloaded-file paths from old download directories to a new one (Android download-dir
170/// migration). For each downloaded attachment whose path starts with an old prefix: move it to
171/// `new_dir/<filename>` if that exists, else mark it not-downloaded. Returns the affected event ids.
172pub fn rewrite_downloaded_paths(old_prefixes: &[String], new_dir: &std::path::Path) -> Result<Vec<String>, String> {
173    let conn = super::get_write_connection_guard_static()?;
174    let rows: Vec<(i64, String, String)> = {
175        let mut stmt = conn.prepare("SELECT id, event_id, path FROM attachments WHERE downloaded=1 AND path!=''")
176            .map_err(|e| format!("prepare rewrite_paths: {e}"))?;
177        let mapped = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))
178            .map_err(|e| format!("query rewrite_paths: {e}"))?;
179        mapped.flatten().collect()
180    };
181    let mut affected = Vec::new();
182    for (rowid, event_id, path) in rows {
183        if !old_prefixes.iter().any(|p| path.starts_with(p.as_str())) {
184            continue;
185        }
186        let Some(name) = std::path::Path::new(&path).file_name() else { continue };
187        let new_path = new_dir.join(name);
188        let res = if new_path.exists() {
189            conn.execute("UPDATE attachments SET path=?1 WHERE id=?2",
190                rusqlite::params![new_path.to_string_lossy().to_string(), rowid])
191        } else {
192            conn.execute("UPDATE attachments SET downloaded=0, path='' WHERE id=?1", rusqlite::params![rowid])
193        };
194        if res.is_ok() {
195            affected.push(event_id);
196        }
197    }
198    Ok(affected)
199}