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/// The message carrying an attachment with this content hash, newest first.
116///
117/// One blob can ride many messages (a forward, a re-post, forty accounts in a
118/// raid posting the same image), so this answers "where did I see this?" rather
119/// than "which message is it". `Attachment.id` IS the content hash, so a
120/// citation's `content_hash` and an attachment's id are the same key.
121pub fn events_with_attachment_hash(hash: &str) -> Result<Vec<String>, String> {
122    let conn = super::get_db_connection_guard_static()?;
123    let mut stmt = conn
124        .prepare(
125            // rowid completes the order: forty accounts posting one image inside
126            // a second is exactly this query's interesting case, and created_at
127            // alone leaves that cluster in whatever order SQLite fancies.
128            "SELECT a.event_id FROM attachments a JOIN events e ON e.id = a.event_id \
129             WHERE a.hash = ?1 ORDER BY e.created_at DESC, e.received_at DESC, e.rowid DESC",
130        )
131        .map_err(|e| format!("prepare events_with_attachment_hash: {e}"))?;
132    let rows = stmt
133        .query_map(rusqlite::params![hash], |r| r.get::<_, String>(0))
134        .map_err(|e| format!("query events_with_attachment_hash: {e}"))?;
135    Ok(rows.flatten().collect())
136}
137
138/// Flip one attachment's downloaded state — a single-row UPDATE keyed by (event_id, content hash),
139/// replacing the old read-modify-write of the whole tags blob.
140pub fn set_attachment_downloaded(event_id: &str, hash: &str, downloaded: bool, path: &str) -> Result<(), String> {
141    let conn = super::get_write_connection_guard_static()?;
142    conn.execute(
143        "UPDATE attachments SET downloaded=?1, path=?2 WHERE event_id=?3 AND hash=?4",
144        rusqlite::params![downloaded as i64, path, event_id, hash],
145    ).map_err(|e| format!("set_attachment_downloaded: {e}"))?;
146    Ok(())
147}
148
149/// Mark every OTHER attachment sharing this content hash as downloaded to the same path — the
150/// download-sharing dedup, now an indexed `WHERE hash = ?` instead of a `LIKE '%hash%'` table scan.
151/// Returns the affected event ids so the caller can reconcile in-memory STATE.
152pub fn backfill_downloaded_by_hash(hash: &str, path: &str, exclude_event_id: &str) -> Result<Vec<String>, String> {
153    let conn = super::get_write_connection_guard_static()?;
154    let affected: Vec<String> = {
155        let mut stmt = conn.prepare(
156            "SELECT DISTINCT event_id FROM attachments WHERE hash=?1 AND event_id!=?2 AND downloaded=0"
157        ).map_err(|e| format!("prepare backfill_by_hash: {e}"))?;
158        let rows = stmt.query_map(rusqlite::params![hash, exclude_event_id], |r| r.get::<_, String>(0))
159            .map_err(|e| format!("query backfill_by_hash: {e}"))?;
160        rows.flatten().collect()
161    };
162    conn.execute(
163        "UPDATE attachments SET downloaded=1, path=?1 WHERE hash=?2 AND event_id!=?3 AND downloaded=0",
164        rusqlite::params![path, hash, exclude_event_id],
165    ).map_err(|e| format!("backfill_by_hash update: {e}"))?;
166    Ok(affected)
167}
168
169/// A downloaded attachment's on-disk path, for the integrity sweep. Returns (event_id, hash, path)
170/// for every attachment claiming `downloaded=1` with a non-empty path — an indexed read, no per-event
171/// JSON parse.
172pub fn downloaded_attachment_paths() -> Result<Vec<(String, String, String)>, String> {
173    let conn = super::get_db_connection_guard_static()?;
174    let mut stmt = conn.prepare(
175        "SELECT event_id, hash, path FROM attachments WHERE downloaded=1 AND path!=''"
176    ).map_err(|e| format!("prepare downloaded_paths: {e}"))?;
177    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))
178        .map_err(|e| format!("query downloaded_paths: {e}"))?;
179    Ok(rows.flatten().collect())
180}
181
182/// Mark an attachment not-downloaded (its file went missing). Clears the path.
183pub fn clear_attachment_download(event_id: &str, hash: &str) -> Result<(), String> {
184    let conn = super::get_write_connection_guard_static()?;
185    conn.execute(
186        "UPDATE attachments SET downloaded=0, path='' WHERE event_id=?1 AND hash=?2",
187        rusqlite::params![event_id, hash],
188    ).map_err(|e| format!("clear_attachment_download: {e}"))?;
189    Ok(())
190}
191
192/// Repoint downloaded-file paths from old download directories to a new one (Android download-dir
193/// migration). For each downloaded attachment whose path starts with an old prefix: move it to
194/// `new_dir/<filename>` if that exists, else mark it not-downloaded. Returns the affected event ids.
195pub fn rewrite_downloaded_paths(old_prefixes: &[String], new_dir: &std::path::Path) -> Result<Vec<String>, String> {
196    let conn = super::get_write_connection_guard_static()?;
197    let rows: Vec<(i64, String, String)> = {
198        let mut stmt = conn.prepare("SELECT id, event_id, path FROM attachments WHERE downloaded=1 AND path!=''")
199            .map_err(|e| format!("prepare rewrite_paths: {e}"))?;
200        let mapped = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))
201            .map_err(|e| format!("query rewrite_paths: {e}"))?;
202        mapped.flatten().collect()
203    };
204    let mut affected = Vec::new();
205    for (rowid, event_id, path) in rows {
206        if !old_prefixes.iter().any(|p| path.starts_with(p.as_str())) {
207            continue;
208        }
209        let Some(name) = std::path::Path::new(&path).file_name() else { continue };
210        let new_path = new_dir.join(name);
211        let res = if new_path.exists() {
212            conn.execute("UPDATE attachments SET path=?1 WHERE id=?2",
213                rusqlite::params![new_path.to_string_lossy().to_string(), rowid])
214        } else {
215            conn.execute("UPDATE attachments SET downloaded=0, path='' WHERE id=?1", rusqlite::params![rowid])
216        };
217        if res.is_ok() {
218            affected.push(event_id);
219        }
220    }
221    Ok(affected)
222}