Skip to main content

huddle_core/storage/
repo.rs

1use rusqlite::params;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4
5use crate::error::Result;
6use crate::storage::Db;
7
8// =========================================================================
9// Identity (unchanged — single row, our own Ed25519 + vodozemac account)
10// =========================================================================
11
12#[derive(Debug, Clone)]
13pub struct StoredIdentity {
14    pub ed25519_secret: Vec<u8>,
15    pub created_at: i64,
16}
17
18pub fn save_identity(db: &Db, secret: &[u8], created_at: i64) -> Result<()> {
19    let conn = db.lock().unwrap();
20    conn.execute(
21        "INSERT OR REPLACE INTO identity (id, ed25519_secret, olm_account_data, created_at) VALUES (1, ?1, NULL, ?2)",
22        params![secret, created_at],
23    )?;
24    Ok(())
25}
26
27pub fn load_identity(db: &Db) -> Result<Option<StoredIdentity>> {
28    let conn = db.lock().unwrap();
29    let mut stmt = conn.prepare("SELECT ed25519_secret, created_at FROM identity WHERE id = 1")?;
30    let mut rows = stmt.query_map([], |row| {
31        Ok(StoredIdentity {
32            ed25519_secret: row.get(0)?,
33            created_at: row.get(1)?,
34        })
35    })?;
36    match rows.next() {
37        Some(row) => Ok(Some(row?)),
38        None => Ok(None),
39    }
40}
41
42pub fn get_display_name(db: &Db) -> Result<Option<String>> {
43    let conn = db.lock().unwrap();
44    let mut stmt = conn.prepare("SELECT display_name FROM identity WHERE id = 1")?;
45    let mut rows = stmt.query_map([], |row| row.get::<_, Option<String>>(0))?;
46    Ok(rows.next().and_then(|r| r.ok()).flatten())
47}
48
49pub fn set_display_name(db: &Db, name: Option<&str>) -> Result<()> {
50    let conn = db.lock().unwrap();
51    conn.execute(
52        "UPDATE identity SET display_name = ?1 WHERE id = 1",
53        params![name],
54    )?;
55    Ok(())
56}
57
58/// Look up the most-recently-seen display name for a given fingerprint
59/// across all rooms. huddle 0.7.11: pre-0.7.11 the doc comment claimed
60/// per-room scoping ("in a room (or anywhere if room_id is empty)"),
61/// but the function signature takes no room_id. The implementation has
62/// always been room-agnostic — pick the freshest `last_seen` regardless
63/// of which room set the display name. Doc updated to match reality.
64/// Callers that need per-room scoping should use the room_members table
65/// directly with an explicit `room_id` filter.
66pub fn lookup_display_name(db: &Db, fingerprint: &str) -> Result<Option<String>> {
67    let conn = db.lock().unwrap();
68    let mut stmt = conn.prepare(
69        "SELECT display_name FROM room_members
70         WHERE fingerprint = ?1 AND display_name IS NOT NULL
71         ORDER BY last_seen DESC LIMIT 1",
72    )?;
73    let mut rows = stmt.query_map(params![fingerprint], |row| row.get::<_, Option<String>>(0))?;
74    Ok(rows.next().and_then(|r| r.ok()).flatten())
75}
76
77pub fn set_member_display_name(
78    db: &Db,
79    room_id: &str,
80    fingerprint: &str,
81    name: Option<&str>,
82) -> Result<()> {
83    let conn = db.lock().unwrap();
84    conn.execute(
85        "UPDATE room_members SET display_name = ?1 WHERE room_id = ?2 AND fingerprint = ?3",
86        params![name, room_id, fingerprint],
87    )?;
88    Ok(())
89}
90
91// =========================================================================
92// Rooms
93// =========================================================================
94
95/// huddle 0.7: explicit room kind. `Direct` = 1-1 DM (encrypted, no name,
96/// no member-list chrome, no kick/grant). `Group` = N-way room (full
97/// moderation, named, optionally encrypted). Persisted on `rooms.kind` and
98/// echoed on `RoomAnnouncement.kind` (with `#[serde(default)]` so pre-0.7
99/// peers' announcements deserialize as `Group`).
100#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum RoomKind {
103    Direct,
104    #[default]
105    Group,
106}
107
108impl RoomKind {
109    pub fn as_str(&self) -> &'static str {
110        match self {
111            RoomKind::Direct => "direct",
112            RoomKind::Group => "group",
113        }
114    }
115
116    pub fn from_str(s: &str) -> Self {
117        match s {
118            "direct" => RoomKind::Direct,
119            _ => RoomKind::Group,
120        }
121    }
122}
123
124#[derive(Debug, Clone)]
125pub struct StoredRoom {
126    pub id: String,
127    pub name: String,
128    pub creator_fingerprint: String,
129    pub encrypted: bool,
130    pub passphrase_salt: Option<Vec<u8>>,
131    pub created_at: i64,
132    pub last_active: Option<i64>,
133    /// huddle 0.7: explicit room kind. Defaults to `Group` for back-fill
134    /// safety on pre-0.7 databases (the column has `DEFAULT 'group'`).
135    pub kind: RoomKind,
136}
137
138/// Derive a stable room ID from creator fingerprint, name, and creation time.
139pub fn derive_room_id(creator_fp: &str, name: &str, created_at: i64) -> String {
140    let mut hasher = Sha256::new();
141    hasher.update(creator_fp.as_bytes());
142    hasher.update(b"\0");
143    hasher.update(name.as_bytes());
144    hasher.update(b"\0");
145    hasher.update(created_at.to_be_bytes());
146    hex::encode(&hasher.finalize()[..16])
147}
148
149/// Insert a room, or update it in place on id collision. Uses a real
150/// UPSERT (not `INSERT OR REPLACE`) so no implicit DELETE fires — the
151/// `ON DELETE CASCADE` on room_megolm_sessions / room_members /
152/// room_messages / room_attachments must never be triggered here.
153/// `created_at`, `creator_fingerprint`, and `encrypted` are immutable
154/// once set and are deliberately not updated on conflict.
155pub fn insert_room(db: &Db, room: &StoredRoom) -> Result<()> {
156    let conn = db.lock().unwrap();
157    conn.execute(
158        "INSERT INTO rooms (id, name, creator_fingerprint, encrypted, passphrase_salt, created_at, last_active, kind)
159         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
160         ON CONFLICT(id) DO UPDATE SET
161            name = excluded.name,
162            passphrase_salt = excluded.passphrase_salt,
163            last_active = excluded.last_active",
164        params![
165            room.id,
166            room.name,
167            room.creator_fingerprint,
168            room.encrypted as i64,
169            room.passphrase_salt,
170            room.created_at,
171            room.last_active,
172            room.kind.as_str(),
173        ],
174    )?;
175    Ok(())
176}
177
178pub fn get_room(db: &Db, room_id: &str) -> Result<Option<StoredRoom>> {
179    let conn = db.lock().unwrap();
180    let mut stmt = conn.prepare(
181        "SELECT id, name, creator_fingerprint, encrypted, passphrase_salt, created_at, last_active, kind
182         FROM rooms WHERE id = ?1",
183    )?;
184    let mut rows = stmt.query_map(params![room_id], |row| {
185        Ok(StoredRoom {
186            id: row.get(0)?,
187            name: row.get(1)?,
188            creator_fingerprint: row.get(2)?,
189            encrypted: row.get::<_, i64>(3)? != 0,
190            passphrase_salt: row.get(4)?,
191            created_at: row.get(5)?,
192            last_active: row.get(6)?,
193            kind: RoomKind::from_str(&row.get::<_, String>(7).unwrap_or_else(|_| "group".into())),
194        })
195    })?;
196    match rows.next() {
197        Some(row) => Ok(Some(row?)),
198        None => Ok(None),
199    }
200}
201
202pub fn list_rooms(db: &Db) -> Result<Vec<StoredRoom>> {
203    let conn = db.lock().unwrap();
204    let mut stmt = conn.prepare(
205        "SELECT id, name, creator_fingerprint, encrypted, passphrase_salt, created_at, last_active, kind
206         FROM rooms ORDER BY last_active DESC NULLS LAST, created_at DESC",
207    )?;
208    let rows = stmt.query_map([], |row| {
209        Ok(StoredRoom {
210            id: row.get(0)?,
211            name: row.get(1)?,
212            creator_fingerprint: row.get(2)?,
213            encrypted: row.get::<_, i64>(3)? != 0,
214            passphrase_salt: row.get(4)?,
215            created_at: row.get(5)?,
216            last_active: row.get(6)?,
217            kind: RoomKind::from_str(&row.get::<_, String>(7).unwrap_or_else(|_| "group".into())),
218        })
219    })?;
220    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
221}
222
223/// huddle 0.7: find an existing `RoomKind::Direct` room between `our_fp`
224/// and `partner_fp`. Used by `AppHandle::start_direct` to short-circuit
225/// when the DM already exists locally, so the call is idempotent across
226/// reopens.
227pub fn find_dm_with(db: &Db, our_fp: &str, partner_fp: &str) -> Result<Option<StoredRoom>> {
228    let conn = db.lock().unwrap();
229    let mut stmt = conn.prepare(
230        "SELECT r.id, r.name, r.creator_fingerprint, r.encrypted, r.passphrase_salt,
231                r.created_at, r.last_active, r.kind
232         FROM rooms r
233         WHERE r.kind = 'direct'
234           AND EXISTS (SELECT 1 FROM room_members m
235                       WHERE m.room_id = r.id AND m.fingerprint = ?1)
236           AND EXISTS (SELECT 1 FROM room_members m
237                       WHERE m.room_id = r.id AND m.fingerprint = ?2)
238         LIMIT 1",
239    )?;
240    let mut rows = stmt.query_map(params![our_fp, partner_fp], |row| {
241        Ok(StoredRoom {
242            id: row.get(0)?,
243            name: row.get(1)?,
244            creator_fingerprint: row.get(2)?,
245            encrypted: row.get::<_, i64>(3)? != 0,
246            passphrase_salt: row.get(4)?,
247            created_at: row.get(5)?,
248            last_active: row.get(6)?,
249            kind: RoomKind::from_str(&row.get::<_, String>(7).unwrap_or_else(|_| "group".into())),
250        })
251    })?;
252    match rows.next() {
253        Some(row) => Ok(Some(row?)),
254        None => Ok(None),
255    }
256}
257
258pub fn update_room_last_active(db: &Db, room_id: &str, ts: i64) -> Result<()> {
259    let conn = db.lock().unwrap();
260    conn.execute(
261        "UPDATE rooms SET last_active = ?1 WHERE id = ?2",
262        params![ts, room_id],
263    )?;
264    Ok(())
265}
266
267pub fn set_room_muted(db: &Db, room_id: &str, muted: bool) -> Result<()> {
268    let conn = db.lock().unwrap();
269    conn.execute(
270        "UPDATE rooms SET muted = ?1 WHERE id = ?2",
271        params![muted as i64, room_id],
272    )?;
273    Ok(())
274}
275
276pub fn is_room_muted(db: &Db, room_id: &str) -> Result<bool> {
277    let conn = db.lock().unwrap();
278    let mut stmt = conn.prepare("SELECT muted FROM rooms WHERE id = ?1")?;
279    let mut rows = stmt.query_map(params![room_id], |row| row.get::<_, i64>(0))?;
280    Ok(rows.next().map(|r| r.unwrap_or(0) != 0).unwrap_or(false))
281}
282
283// =========================================================================
284// Room members
285// =========================================================================
286
287#[derive(Debug, Clone)]
288pub struct StoredRoomMember {
289    pub room_id: String,
290    pub peer_id: String,
291    pub fingerprint: String,
292    pub last_seen: Option<i64>,
293    pub verified: bool,
294    /// Base64-encoded Ed25519 public key. Populated from the member's
295    /// `MemberAnnounce.sender_ed25519_pubkey` on first contact; required
296    /// to verify `SignedRoomMessage` envelopes from this fingerprint.
297    /// `None` for pre-Phase-0 rows or for peers running older builds.
298    pub ed25519_pubkey: Option<String>,
299    /// Phase B: `"owner"` or `"member"`. Set on first insert
300    /// (`start_room` sets the creator to `"owner"`); never overwritten
301    /// by re-announcements so OwnerGrant is the only way to promote
302    /// after the fact.
303    pub role: String,
304}
305
306/// Insert a member, or update in place on (room_id, fingerprint) collision.
307/// `verified` and `role` are set only on first insert and intentionally
308/// absent from the conflict-update clause: a re-announcement can never
309/// silently reset a member's verified flag or demote an owner to member.
310/// A genuinely new fingerprint is a new (unverified, member) row.
311/// `peer_id` and `ed25519_pubkey` are only overwritten when the incoming
312/// value is non-null/non-empty — a re-announce that drops the pubkey
313/// field must not erase the one we already learned.
314pub fn upsert_room_member(db: &Db, member: &StoredRoomMember) -> Result<()> {
315    let conn = db.lock().unwrap();
316    conn.execute(
317        "INSERT INTO room_members (room_id, peer_id, fingerprint, last_seen, verified, ed25519_pubkey, role)
318         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
319         ON CONFLICT(room_id, fingerprint) DO UPDATE SET
320            last_seen = excluded.last_seen,
321            peer_id = CASE
322                WHEN excluded.peer_id != '' THEN excluded.peer_id
323                ELSE room_members.peer_id
324            END,
325            ed25519_pubkey = COALESCE(excluded.ed25519_pubkey, room_members.ed25519_pubkey)",
326        params![
327            member.room_id,
328            member.peer_id,
329            member.fingerprint,
330            member.last_seen,
331            member.verified as i64,
332            member.ed25519_pubkey,
333            member.role,
334        ],
335    )?;
336    Ok(())
337}
338
339/// huddle 0.7.1: find an Ed25519 pubkey for a fingerprint across all
340/// rooms we've ever seen the peer in. A peer's identity key is global
341/// (not per-room), so any non-null row works. Used by DM E2E to derive
342/// the ECDH room key without re-asking the network.
343pub fn lookup_peer_ed25519_pubkey(db: &Db, fingerprint: &str) -> Result<Option<String>> {
344    let conn = db.lock().unwrap();
345    let mut stmt = conn.prepare(
346        "SELECT ed25519_pubkey FROM room_members
347         WHERE fingerprint = ?1 AND ed25519_pubkey IS NOT NULL
348         LIMIT 1",
349    )?;
350    let mut rows = stmt.query_map(params![fingerprint], |row| row.get::<_, Option<String>>(0))?;
351    Ok(rows.next().and_then(|r| r.ok()).flatten())
352}
353
354pub fn list_room_members(db: &Db, room_id: &str) -> Result<Vec<StoredRoomMember>> {
355    let conn = db.lock().unwrap();
356    let mut stmt = conn.prepare(
357        "SELECT room_id, peer_id, fingerprint, last_seen, verified, ed25519_pubkey, role FROM room_members WHERE room_id = ?1",
358    )?;
359    let rows = stmt.query_map(params![room_id], |row| {
360        Ok(StoredRoomMember {
361            room_id: row.get(0)?,
362            peer_id: row.get(1)?,
363            fingerprint: row.get(2)?,
364            last_seen: row.get(3)?,
365            verified: row.get::<_, i64>(4).unwrap_or(0) != 0,
366            ed25519_pubkey: row.get(5).ok().flatten(),
367            role: row.get(6).unwrap_or_else(|_| "member".to_string()),
368        })
369    })?;
370    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
371}
372
373/// Phase B: promote / demote a member's role. Used by the `OwnerGrant`
374/// handler. Callers must verify the grant signature came from an owner
375/// before invoking — the repo function trusts its inputs.
376pub fn set_member_role(db: &Db, room_id: &str, fingerprint: &str, role: &str) -> Result<()> {
377    let conn = db.lock().unwrap();
378    conn.execute(
379        "UPDATE room_members SET role = ?1 WHERE room_id = ?2 AND fingerprint = ?3",
380        params![role, room_id, fingerprint],
381    )?;
382    Ok(())
383}
384
385/// Phase B: list owners of a room — fingerprints with role = 'owner'.
386/// Used for `RoomAnnouncement.owner_fingerprints` and for verifying
387/// that an incoming `OwnerGrant` / `BanMember` came from a current owner.
388pub fn list_room_owners(db: &Db, room_id: &str) -> Result<Vec<String>> {
389    let conn = db.lock().unwrap();
390    let mut stmt = conn.prepare(
391        "SELECT fingerprint FROM room_members WHERE room_id = ?1 AND role = 'owner'",
392    )?;
393    let rows = stmt.query_map(params![room_id], |row| row.get::<_, String>(0))?;
394    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
395}
396
397/// Phase B: persistent room-level ban. Banned members are ignored on
398/// receive (MemberAnnounce dropped, messages skipped) and excluded from
399/// future session-key wraps. Idempotent.
400pub fn add_room_ban(
401    db: &Db,
402    room_id: &str,
403    banned_fingerprint: &str,
404    banned_by_fingerprint: &str,
405    signature_b64: &str,
406    banned_at: i64,
407) -> Result<()> {
408    let conn = db.lock().unwrap();
409    conn.execute(
410        "INSERT INTO room_bans (room_id, banned_fingerprint, banned_by_fingerprint, signature_b64, banned_at)
411         VALUES (?1, ?2, ?3, ?4, ?5)
412         ON CONFLICT(room_id, banned_fingerprint) DO UPDATE SET
413            banned_by_fingerprint = excluded.banned_by_fingerprint,
414            signature_b64 = excluded.signature_b64,
415            banned_at = excluded.banned_at",
416        params![
417            room_id,
418            banned_fingerprint,
419            banned_by_fingerprint,
420            signature_b64,
421            banned_at,
422        ],
423    )?;
424    Ok(())
425}
426
427pub fn is_member_banned(db: &Db, room_id: &str, fingerprint: &str) -> Result<bool> {
428    let conn = db.lock().unwrap();
429    let count: i64 = conn
430        .query_row(
431            "SELECT COUNT(*) FROM room_bans WHERE room_id = ?1 AND banned_fingerprint = ?2",
432            params![room_id, fingerprint],
433            |r| r.get(0),
434        )
435        .unwrap_or(0);
436    Ok(count > 0)
437}
438
439/// List fingerprints currently banned from a room, newest first. Used
440/// by the `^B` in-room bans view (owners-only) so they can audit who's
441/// been kicked.
442pub fn list_room_bans(db: &Db, room_id: &str) -> Result<Vec<String>> {
443    let conn = db.lock().unwrap();
444    let mut stmt = conn.prepare(
445        "SELECT banned_fingerprint FROM room_bans WHERE room_id = ?1 ORDER BY banned_at DESC",
446    )?;
447    let rows = stmt.query_map(params![room_id], |row| row.get::<_, String>(0))?;
448    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
449}
450
451/// Look up the persisted Ed25519 pubkey (base64) for a member by their
452/// fingerprint. Defense-in-depth check during `SignedRoomMessage`
453/// verification: when a signed envelope arrives, we re-derive the
454/// fingerprint from the envelope's claimed pubkey AND, if we already
455/// know a pubkey for this fingerprint, refuse to accept a different
456/// one. Mismatch ⇒ identity drift / TOFU violation ⇒ drop the message.
457///
458/// Returns `Ok(None)` if the member exists but pre-dates Phase 0 and
459/// hasn't re-announced with their pubkey yet — caller falls back to
460/// TOFU: accept the envelope's claimed pubkey on first contact and
461/// persist it via `upsert_room_member`.
462pub fn get_member_ed25519_pubkey(
463    db: &Db,
464    room_id: &str,
465    fingerprint: &str,
466) -> Result<Option<String>> {
467    let conn = db.lock().unwrap();
468    let mut stmt = conn.prepare(
469        "SELECT ed25519_pubkey FROM room_members WHERE room_id = ?1 AND fingerprint = ?2",
470    )?;
471    let row = stmt
472        .query_row(params![room_id, fingerprint], |row| {
473            row.get::<_, Option<String>>(0)
474        })
475        .ok();
476    Ok(row.flatten())
477}
478
479pub fn remove_room_member(db: &Db, room_id: &str, fingerprint: &str) -> Result<()> {
480    let conn = db.lock().unwrap();
481    conn.execute(
482        "DELETE FROM room_members WHERE room_id = ?1 AND fingerprint = ?2",
483        params![room_id, fingerprint],
484    )?;
485    Ok(())
486}
487
488/// Mark a member as verified-by-fingerprint. Matches by fingerprint
489/// rather than peer_id so a re-join (new peer_id) keeps verification.
490pub fn set_member_verified(
491    db: &Db,
492    room_id: &str,
493    fingerprint: &str,
494    verified: bool,
495) -> Result<()> {
496    let conn = db.lock().unwrap();
497    conn.execute(
498        "UPDATE room_members SET verified = ?1 WHERE room_id = ?2 AND fingerprint = ?3",
499        params![verified as i64, room_id, fingerprint],
500    )?;
501    Ok(())
502}
503
504pub fn list_verified_fingerprints(db: &Db, room_id: &str) -> Result<Vec<String>> {
505    let conn = db.lock().unwrap();
506    let mut stmt = conn.prepare(
507        "SELECT DISTINCT fingerprint FROM room_members WHERE room_id = ?1 AND verified = 1",
508    )?;
509    let rows = stmt.query_map(params![room_id], |row| row.get::<_, String>(0))?;
510    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
511}
512
513// =========================================================================
514// Megolm sessions
515// =========================================================================
516
517#[derive(Debug, Clone)]
518pub struct StoredMegolmSession {
519    pub room_id: String,
520    pub sender_fingerprint: String,
521    pub session_id: String,
522    pub session_data: Vec<u8>,
523    pub is_outbound: bool,
524    pub created_at: i64,
525}
526
527pub fn save_megolm_session(db: &Db, session: &StoredMegolmSession) -> Result<()> {
528    let conn = db.lock().unwrap();
529    conn.execute(
530        "INSERT OR REPLACE INTO room_megolm_sessions
531            (room_id, sender_fingerprint, session_id, session_data, is_outbound, created_at)
532         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
533        params![
534            session.room_id,
535            session.sender_fingerprint,
536            session.session_id,
537            session.session_data,
538            session.is_outbound as i64,
539            session.created_at,
540        ],
541    )?;
542    Ok(())
543}
544
545pub fn load_megolm_sessions_for_room(
546    db: &Db,
547    room_id: &str,
548) -> Result<Vec<StoredMegolmSession>> {
549    let conn = db.lock().unwrap();
550    let mut stmt = conn.prepare(
551        "SELECT room_id, sender_fingerprint, session_id, session_data, is_outbound, created_at
552         FROM room_megolm_sessions WHERE room_id = ?1",
553    )?;
554    let rows = stmt.query_map(params![room_id], |row| {
555        Ok(StoredMegolmSession {
556            room_id: row.get(0)?,
557            sender_fingerprint: row.get(1)?,
558            session_id: row.get(2)?,
559            session_data: row.get(3)?,
560            is_outbound: row.get::<_, i64>(4)? != 0,
561            created_at: row.get(5)?,
562        })
563    })?;
564    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
565}
566
567// =========================================================================
568// Room messages
569// =========================================================================
570
571#[derive(Debug, Clone)]
572pub struct StoredRoomMessage {
573    pub id: i64,
574    pub room_id: String,
575    pub sender_fingerprint: String,
576    pub direction: String,
577    pub body: String,
578    pub sent_at: i64,
579}
580
581pub fn insert_room_message(
582    db: &Db,
583    room_id: &str,
584    sender_fingerprint: &str,
585    direction: &str,
586    body: &str,
587    sent_at: i64,
588) -> Result<i64> {
589    let conn = db.lock().unwrap();
590    conn.execute(
591        "INSERT INTO room_messages (room_id, sender_fingerprint, direction, body, sent_at)
592         VALUES (?1, ?2, ?3, ?4, ?5)",
593        params![room_id, sender_fingerprint, direction, body, sent_at],
594    )?;
595    Ok(conn.last_insert_rowid())
596}
597
598/// LIKE-based message search within a room. Case-insensitive. The query
599/// is treated as a literal substring — `%`, `_`, and `\` are escaped so
600/// they cannot act as LIKE wildcards.
601pub fn search_room_messages(
602    db: &Db,
603    room_id: &str,
604    query: &str,
605    limit: i64,
606) -> Result<Vec<StoredRoomMessage>> {
607    // Escape `\` first so the escapes added for `%` / `_` aren't doubled.
608    let escaped = query
609        .replace('\\', "\\\\")
610        .replace('%', "\\%")
611        .replace('_', "\\_");
612    let pattern = format!("%{}%", escaped);
613    let conn = db.lock().unwrap();
614    let mut stmt = conn.prepare(
615        "SELECT id, room_id, sender_fingerprint, direction, body, sent_at
616         FROM room_messages
617         WHERE room_id = ?1 AND body LIKE ?2 ESCAPE '\\' COLLATE NOCASE
618         ORDER BY sent_at DESC LIMIT ?3",
619    )?;
620    let rows = stmt.query_map(params![room_id, pattern, limit], |row| {
621        Ok(StoredRoomMessage {
622            id: row.get(0)?,
623            room_id: row.get(1)?,
624            sender_fingerprint: row.get(2)?,
625            direction: row.get(3)?,
626            body: row.get(4)?,
627            sent_at: row.get(5)?,
628        })
629    })?;
630    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
631}
632
633pub fn get_room_messages(db: &Db, room_id: &str, limit: i64) -> Result<Vec<StoredRoomMessage>> {
634    let conn = db.lock().unwrap();
635    let mut stmt = conn.prepare(
636        "SELECT id, room_id, sender_fingerprint, direction, body, sent_at
637         FROM room_messages WHERE room_id = ?1 ORDER BY sent_at DESC LIMIT ?2",
638    )?;
639    let rows = stmt.query_map(params![room_id, limit], |row| {
640        Ok(StoredRoomMessage {
641            id: row.get(0)?,
642            room_id: row.get(1)?,
643            sender_fingerprint: row.get(2)?,
644            direction: row.get(3)?,
645            body: row.get(4)?,
646            sent_at: row.get(5)?,
647        })
648    })?;
649    let mut msgs: Vec<StoredRoomMessage> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
650    msgs.reverse();
651    Ok(msgs)
652}
653
654// =========================================================================
655// Known peers (manually dialed addresses we want to auto-reconnect to)
656// =========================================================================
657
658#[derive(Debug, Clone)]
659pub struct KnownPeer {
660    pub address: String,
661    pub label: Option<String>,
662    pub last_connected_at: Option<i64>,
663    pub last_attempt_at: Option<i64>,
664    pub created_at: i64,
665    /// Phase A: the peer's Ed25519 fingerprint, learned from Identify
666    /// after the first successful connection. `None` for rows from
667    /// pre-Phase-A and for peers that haven't been reached yet.
668    pub fingerprint: Option<String>,
669    /// Phase A: `true` once the user explicitly trusted this peer
670    /// ("Trust + Accept" on the inbound-dial modal, or any successful
671    /// user-initiated outbound dial). Trusted peers bypass the inbound
672    /// prompt on reconnect.
673    pub trusted: bool,
674}
675
676pub fn upsert_known_peer(db: &Db, peer: &KnownPeer) -> Result<()> {
677    let conn = db.lock().unwrap();
678    conn.execute(
679        "INSERT INTO known_peers (address, label, last_connected_at, last_attempt_at, created_at, fingerprint, trusted)
680         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
681         ON CONFLICT(address) DO UPDATE SET
682           label = COALESCE(excluded.label, known_peers.label),
683           last_connected_at = COALESCE(excluded.last_connected_at, known_peers.last_connected_at),
684           last_attempt_at = COALESCE(excluded.last_attempt_at, known_peers.last_attempt_at),
685           fingerprint = COALESCE(excluded.fingerprint, known_peers.fingerprint),
686           -- trusted is sticky-once-true: a fresh upsert with trusted=false
687           -- (the default on auto-reconnect) must not demote a previously
688           -- trusted row.
689           trusted = CASE
690             WHEN excluded.trusted = 1 THEN 1
691             ELSE known_peers.trusted
692           END",
693        params![
694            peer.address,
695            peer.label,
696            peer.last_connected_at,
697            peer.last_attempt_at,
698            peer.created_at,
699            peer.fingerprint,
700            peer.trusted as i64,
701        ],
702    )?;
703    Ok(())
704}
705
706pub fn list_known_peers(db: &Db) -> Result<Vec<KnownPeer>> {
707    let conn = db.lock().unwrap();
708    let mut stmt = conn.prepare(
709        "SELECT address, label, last_connected_at, last_attempt_at, created_at, fingerprint, trusted
710         FROM known_peers ORDER BY COALESCE(last_connected_at, 0) DESC, created_at DESC",
711    )?;
712    let rows = stmt.query_map([], |row| {
713        Ok(KnownPeer {
714            address: row.get(0)?,
715            label: row.get(1)?,
716            last_connected_at: row.get(2)?,
717            last_attempt_at: row.get(3)?,
718            created_at: row.get(4)?,
719            fingerprint: row.get(5).ok().flatten(),
720            trusted: row.get::<_, i64>(6).unwrap_or(0) != 0,
721        })
722    })?;
723    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
724}
725
726pub fn forget_known_peer(db: &Db, address: &str) -> Result<()> {
727    let conn = db.lock().unwrap();
728    conn.execute("DELETE FROM known_peers WHERE address = ?1", params![address])?;
729    Ok(())
730}
731
732/// Phase A: look up whether we've already trusted a peer by fingerprint.
733/// Used by the network task when an inbound connection's Identify lands —
734/// trusted fingerprints bypass the user-prompt modal.
735pub fn is_fingerprint_trusted(db: &Db, fingerprint: &str) -> Result<bool> {
736    let conn = db.lock().unwrap();
737    let count: i64 = conn
738        .query_row(
739            "SELECT COUNT(*) FROM known_peers WHERE fingerprint = ?1 AND trusted = 1",
740            params![fingerprint],
741            |r| r.get(0),
742        )
743        .unwrap_or(0);
744    Ok(count > 0)
745}
746
747// =========================================================================
748// huddle 1.0: Contacts — the durable, fingerprint-keyed address book
749// =========================================================================
750
751#[derive(Debug, Clone)]
752pub struct Contact {
753    pub fingerprint: String,
754    pub alias: Option<String>,
755    pub ed25519_pubkey: Option<String>,
756    pub dm_room_id: Option<String>,
757    pub source: String,
758    pub note: Option<String>,
759    pub added_at: i64,
760    pub last_seen: Option<i64>,
761}
762
763fn row_to_contact(row: &rusqlite::Row) -> rusqlite::Result<Contact> {
764    Ok(Contact {
765        fingerprint: row.get(0)?,
766        alias: row.get(1)?,
767        ed25519_pubkey: row.get(2)?,
768        dm_room_id: row.get(3)?,
769        source: row.get(4)?,
770        note: row.get(5)?,
771        added_at: row.get(6)?,
772        last_seen: row.get(7)?,
773    })
774}
775
776/// Insert or fill-in a contact. Existing non-NULL fields are preserved
777/// (COALESCE) so a later, sparser upsert never erases an alias / pubkey we
778/// already learned; `last_seen` advances when provided and `source` is set
779/// only on first insert. Use `set_contact_alias` to deliberately change an
780/// alias.
781pub fn upsert_contact(db: &Db, c: &Contact) -> Result<()> {
782    let conn = db.lock().unwrap();
783    conn.execute(
784        "INSERT INTO contacts (fingerprint, alias, ed25519_pubkey, dm_room_id, source, note, added_at, last_seen)
785         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
786         ON CONFLICT(fingerprint) DO UPDATE SET
787           alias = COALESCE(excluded.alias, contacts.alias),
788           ed25519_pubkey = COALESCE(excluded.ed25519_pubkey, contacts.ed25519_pubkey),
789           dm_room_id = COALESCE(excluded.dm_room_id, contacts.dm_room_id),
790           note = COALESCE(excluded.note, contacts.note),
791           last_seen = COALESCE(excluded.last_seen, contacts.last_seen)",
792        params![
793            c.fingerprint,
794            c.alias,
795            c.ed25519_pubkey,
796            c.dm_room_id,
797            c.source,
798            c.note,
799            c.added_at,
800            c.last_seen,
801        ],
802    )?;
803    Ok(())
804}
805
806pub fn get_contact(db: &Db, fingerprint: &str) -> Result<Option<Contact>> {
807    let conn = db.lock().unwrap();
808    let mut stmt = conn.prepare(
809        "SELECT fingerprint, alias, ed25519_pubkey, dm_room_id, source, note, added_at, last_seen
810         FROM contacts WHERE fingerprint = ?1",
811    )?;
812    let mut rows = stmt.query_map(params![fingerprint], row_to_contact)?;
813    match rows.next() {
814        Some(r) => Ok(Some(r?)),
815        None => Ok(None),
816    }
817}
818
819pub fn list_contacts(db: &Db) -> Result<Vec<Contact>> {
820    let conn = db.lock().unwrap();
821    let mut stmt = conn.prepare(
822        "SELECT fingerprint, alias, ed25519_pubkey, dm_room_id, source, note, added_at, last_seen
823         FROM contacts ORDER BY COALESCE(last_seen, added_at) DESC",
824    )?;
825    let rows = stmt.query_map([], row_to_contact)?;
826    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
827}
828
829pub fn delete_contact(db: &Db, fingerprint: &str) -> Result<()> {
830    let conn = db.lock().unwrap();
831    conn.execute("DELETE FROM contacts WHERE fingerprint = ?1", params![fingerprint])?;
832    Ok(())
833}
834
835pub fn is_contact(db: &Db, fingerprint: &str) -> Result<bool> {
836    let conn = db.lock().unwrap();
837    let count: i64 = conn
838        .query_row(
839            "SELECT COUNT(*) FROM contacts WHERE fingerprint = ?1",
840            params![fingerprint],
841            |r| r.get(0),
842        )
843        .unwrap_or(0);
844    Ok(count > 0)
845}
846
847/// Deliberately set (or clear, with None) a contact's user-chosen alias.
848/// Unlike `upsert_contact`, this overwrites — it's the explicit edit path.
849pub fn set_contact_alias(db: &Db, fingerprint: &str, alias: Option<&str>) -> Result<()> {
850    let conn = db.lock().unwrap();
851    conn.execute(
852        "UPDATE contacts SET alias = ?2 WHERE fingerprint = ?1",
853        params![fingerprint, alias],
854    )?;
855    Ok(())
856}
857
858// =========================================================================
859// huddle 0.7.7: pending friend requests
860// =========================================================================
861
862/// Pending inbound dial that the user hasn't yet acted on. Persisted so a
863/// brief absence (or app restart) doesn't lose the request. Auto-rejected
864/// when older than [`PENDING_FRIEND_REQUEST_TTL_SECS`] (3 days).
865#[derive(Debug, Clone)]
866pub struct PendingFriendRequest {
867    pub fingerprint: String,
868    pub address: String,
869    pub peer_id: String,
870    pub received_at: i64,
871}
872
873/// 3 days, in seconds. Anything older is auto-rejected by the startup
874/// sweep — long enough to cover a weekend away from the keyboard, short
875/// enough that an actively-malicious peer's pending row doesn't linger
876/// indefinitely.
877pub const PENDING_FRIEND_REQUEST_TTL_SECS: i64 = 3 * 24 * 60 * 60;
878
879pub fn upsert_pending_friend_request(db: &Db, req: &PendingFriendRequest) -> Result<()> {
880    let conn = db.lock().unwrap();
881    conn.execute(
882        "INSERT INTO pending_friend_requests (fingerprint, address, peer_id, received_at)
883         VALUES (?1, ?2, ?3, ?4)
884         ON CONFLICT(fingerprint, address) DO UPDATE SET
885           peer_id = excluded.peer_id,
886           received_at = excluded.received_at",
887        params![req.fingerprint, req.address, req.peer_id, req.received_at],
888    )?;
889    Ok(())
890}
891
892pub fn list_pending_friend_requests(db: &Db) -> Result<Vec<PendingFriendRequest>> {
893    let conn = db.lock().unwrap();
894    let mut stmt = conn.prepare(
895        "SELECT fingerprint, address, peer_id, received_at
896         FROM pending_friend_requests
897         ORDER BY received_at DESC",
898    )?;
899    let rows = stmt.query_map([], |row| {
900        Ok(PendingFriendRequest {
901            fingerprint: row.get(0)?,
902            address: row.get(1)?,
903            peer_id: row.get(2)?,
904            received_at: row.get(3)?,
905        })
906    })?;
907    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
908}
909
910/// Delete every row matching `fingerprint`. Both Accept and Reject paths
911/// clear all of the peer's pending rows at once — accepting one address
912/// implicitly accepts the peer, and we don't want a second row for the
913/// same fp to re-prompt later.
914pub fn delete_pending_friend_requests_for_fp(db: &Db, fingerprint: &str) -> Result<()> {
915    let conn = db.lock().unwrap();
916    conn.execute(
917        "DELETE FROM pending_friend_requests WHERE fingerprint = ?1",
918        params![fingerprint],
919    )?;
920    Ok(())
921}
922
923/// Drop rows older than the TTL. Called once on startup; returns the
924/// number of rows pruned so callers can surface a status hint if any
925/// pending requests aged out while the user was offline.
926pub fn cleanup_expired_pending_friend_requests(db: &Db, now: i64) -> Result<usize> {
927    // huddle 0.7.11: saturating_sub guards against `now < TTL` (occurs
928    // in tests with hand-crafted timestamps and on freshly-reset clocks)
929    // where a plain `now - TTL` would go negative and match every row.
930    let cutoff = now.saturating_sub(PENDING_FRIEND_REQUEST_TTL_SECS);
931    let conn = db.lock().unwrap();
932    let removed = conn.execute(
933        "DELETE FROM pending_friend_requests WHERE received_at < ?1",
934        params![cutoff],
935    )?;
936    Ok(removed)
937}
938
939// =========================================================================
940// huddle 1.0: pending contact requests (relay inbox — Phase 1)
941// =========================================================================
942
943/// An inbound contact/DM request that arrived over the relay inbox but
944/// hasn't been accepted/declined yet. Keyed by fingerprint (relay requests
945/// carry no dialable address — just the requester's signed identity). Shares
946/// the 3-day [`PENDING_FRIEND_REQUEST_TTL_SECS`] sweep.
947#[derive(Debug, Clone)]
948pub struct PendingContactRequest {
949    pub fingerprint: String,
950    pub display_name: Option<String>,
951    pub note: Option<String>,
952    pub received_at: i64,
953}
954
955pub fn upsert_pending_contact_request(db: &Db, req: &PendingContactRequest) -> Result<()> {
956    let conn = db.lock().unwrap();
957    conn.execute(
958        "INSERT INTO pending_contact_requests (fingerprint, display_name, note, received_at)
959         VALUES (?1, ?2, ?3, ?4)
960         ON CONFLICT(fingerprint) DO UPDATE SET
961           display_name = COALESCE(excluded.display_name, pending_contact_requests.display_name),
962           note = COALESCE(excluded.note, pending_contact_requests.note),
963           received_at = excluded.received_at",
964        params![req.fingerprint, req.display_name, req.note, req.received_at],
965    )?;
966    Ok(())
967}
968
969pub fn list_pending_contact_requests(db: &Db) -> Result<Vec<PendingContactRequest>> {
970    let conn = db.lock().unwrap();
971    let mut stmt = conn.prepare(
972        "SELECT fingerprint, display_name, note, received_at
973         FROM pending_contact_requests ORDER BY received_at DESC",
974    )?;
975    let rows = stmt.query_map([], |row| {
976        Ok(PendingContactRequest {
977            fingerprint: row.get(0)?,
978            display_name: row.get(1)?,
979            note: row.get(2)?,
980            received_at: row.get(3)?,
981        })
982    })?;
983    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
984}
985
986pub fn delete_pending_contact_request(db: &Db, fingerprint: &str) -> Result<()> {
987    let conn = db.lock().unwrap();
988    conn.execute(
989        "DELETE FROM pending_contact_requests WHERE fingerprint = ?1",
990        params![fingerprint],
991    )?;
992    Ok(())
993}
994
995pub fn cleanup_expired_pending_contact_requests(db: &Db, now: i64) -> Result<usize> {
996    let cutoff = now.saturating_sub(PENDING_FRIEND_REQUEST_TTL_SECS);
997    let conn = db.lock().unwrap();
998    let removed = conn.execute(
999        "DELETE FROM pending_contact_requests WHERE received_at < ?1",
1000        params![cutoff],
1001    )?;
1002    Ok(removed)
1003}
1004
1005/// Phase A: persistent blocklist. A fingerprint here means we explicitly
1006/// rejected an inbound dial from this peer — every subsequent connection
1007/// attempt is auto-disconnected without raising the modal.
1008pub fn block_peer(db: &Db, fingerprint: &str, now: i64) -> Result<()> {
1009    let conn = db.lock().unwrap();
1010    conn.execute(
1011        "INSERT INTO blocked_peers (fingerprint, blocked_at) VALUES (?1, ?2)
1012         ON CONFLICT(fingerprint) DO UPDATE SET blocked_at = excluded.blocked_at",
1013        params![fingerprint, now],
1014    )?;
1015    Ok(())
1016}
1017
1018/// Phase E: simple app-wide KV. Used for the global
1019/// 'verified_only_inbound' toggle and any other future flags.
1020pub fn get_setting(db: &Db, key: &str) -> Result<Option<String>> {
1021    let conn = db.lock().unwrap();
1022    let mut stmt = conn.prepare("SELECT value FROM app_settings WHERE key = ?1")?;
1023    let row = stmt
1024        .query_row(params![key], |r| r.get::<_, String>(0))
1025        .ok();
1026    Ok(row)
1027}
1028
1029pub fn set_setting(db: &Db, key: &str, value: &str) -> Result<()> {
1030    let conn = db.lock().unwrap();
1031    conn.execute(
1032        "INSERT INTO app_settings (key, value) VALUES (?1, ?2)
1033         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
1034        params![key, value],
1035    )?;
1036    Ok(())
1037}
1038
1039/// Phase E: per-room "only verified members may join" toggle.
1040pub fn get_room_verified_only(db: &Db, room_id: &str) -> Result<bool> {
1041    let conn = db.lock().unwrap();
1042    let v: i64 = conn
1043        .query_row(
1044            "SELECT verified_only_join FROM rooms WHERE id = ?1",
1045            params![room_id],
1046            |r| r.get(0),
1047        )
1048        .unwrap_or(0);
1049    Ok(v != 0)
1050}
1051
1052pub fn set_room_verified_only(db: &Db, room_id: &str, on: bool) -> Result<()> {
1053    let conn = db.lock().unwrap();
1054    conn.execute(
1055        "UPDATE rooms SET verified_only_join = ?1 WHERE id = ?2",
1056        params![on as i64, room_id],
1057    )?;
1058    Ok(())
1059}
1060
1061/// Phase G: mark a fingerprint as globally SAS-verified. Idempotent;
1062/// re-verifying just refreshes `verified_at`. Used by both sides of
1063/// an SAS exchange on receiving the partner's matching `SasConfirm`.
1064pub fn add_verified_peer(db: &Db, fingerprint: &str, verified_at: i64) -> Result<()> {
1065    let conn = db.lock().unwrap();
1066    conn.execute(
1067        "INSERT INTO verified_peers (fingerprint, verified_at) VALUES (?1, ?2)
1068         ON CONFLICT(fingerprint) DO UPDATE SET verified_at = excluded.verified_at",
1069        params![fingerprint, verified_at],
1070    )?;
1071    Ok(())
1072}
1073
1074/// Phase G + E: is this fingerprint globally SAS-verified? Used by
1075/// Phase E's global inbound filter and by the per-room "verified_only"
1076/// enforcement.
1077pub fn is_globally_verified(db: &Db, fingerprint: &str) -> Result<bool> {
1078    let conn = db.lock().unwrap();
1079    let count: i64 = conn
1080        .query_row(
1081            "SELECT COUNT(*) FROM verified_peers WHERE fingerprint = ?1",
1082            params![fingerprint],
1083            |r| r.get(0),
1084        )
1085        .unwrap_or(0);
1086    Ok(count > 0)
1087}
1088
1089/// huddle 0.7: list every globally SAS-verified fingerprint. Used by
1090/// the People pane to render the "Verified" sub-list.
1091pub fn list_verified_peers(db: &Db) -> Result<Vec<String>> {
1092    let conn = db.lock().unwrap();
1093    let mut stmt =
1094        conn.prepare("SELECT fingerprint FROM verified_peers ORDER BY verified_at DESC")?;
1095    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
1096    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
1097}
1098
1099/// Phase H: has the first-launch onboarding card been dismissed?
1100pub fn is_onboarding_seen(db: &Db) -> Result<bool> {
1101    let conn = db.lock().unwrap();
1102    let v: i64 = conn
1103        .query_row(
1104            "SELECT onboarding_seen FROM identity WHERE id = 1",
1105            [],
1106            |r| r.get(0),
1107        )
1108        .unwrap_or(0);
1109    Ok(v != 0)
1110}
1111
1112pub fn mark_onboarding_seen(db: &Db) -> Result<()> {
1113    let conn = db.lock().unwrap();
1114    conn.execute(
1115        "UPDATE identity SET onboarding_seen = 1 WHERE id = 1",
1116        [],
1117    )?;
1118    Ok(())
1119}
1120
1121/// huddle 0.6: the version string of huddle that this user last
1122/// finished onboarding for. Stored under the app_settings KV so
1123/// version bumps re-fire the "what's new" card without churning
1124/// the identity schema again. `None` means the user hasn't seen
1125/// any onboarding yet OR pre-existed the version-tracking change.
1126pub fn get_last_seen_onboarding_version(db: &Db) -> Result<Option<String>> {
1127    get_setting(db, "last_seen_onboarding_version")
1128}
1129
1130pub fn set_last_seen_onboarding_version(db: &Db, version: &str) -> Result<()> {
1131    set_setting(db, "last_seen_onboarding_version", version)
1132}
1133
1134/// huddle 0.6: opt-in flag for the crates.io update check. None means
1135/// the user hasn't been asked yet; `Some(true)` enables the background
1136/// poll; `Some(false)` disables it.
1137pub fn get_update_check_enabled(db: &Db) -> Result<Option<bool>> {
1138    Ok(get_setting(db, "update_check_enabled")?
1139        .map(|v| v == "1" || v.eq_ignore_ascii_case("true")))
1140}
1141
1142pub fn set_update_check_enabled(db: &Db, enabled: bool) -> Result<()> {
1143    set_setting(db, "update_check_enabled", if enabled { "1" } else { "0" })
1144}
1145
1146pub fn is_peer_blocked(db: &Db, fingerprint: &str) -> Result<bool> {
1147    let conn = db.lock().unwrap();
1148    let count: i64 = conn
1149        .query_row(
1150            "SELECT COUNT(*) FROM blocked_peers WHERE fingerprint = ?1",
1151            params![fingerprint],
1152            |r| r.get(0),
1153        )
1154        .unwrap_or(0);
1155    Ok(count > 0)
1156}
1157
1158/// List every fingerprint we've blocked (across all rooms / global
1159/// rejection from the inbound-dial modal), newest first. Used by the
1160/// Settings modal's "blocked peers" pane to render the unblock action.
1161pub fn list_blocked_peers(db: &Db) -> Result<Vec<String>> {
1162    let conn = db.lock().unwrap();
1163    let mut stmt = conn.prepare(
1164        "SELECT fingerprint FROM blocked_peers ORDER BY blocked_at DESC",
1165    )?;
1166    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
1167    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
1168}
1169
1170/// Remove a fingerprint from the blocklist. Used by the Settings
1171/// modal's "unblock" action so a previously-rejected inbound dial can
1172/// reach us again. Counterpart of `block_peer`.
1173pub fn unblock_peer(db: &Db, fingerprint: &str) -> Result<()> {
1174    let conn = db.lock().unwrap();
1175    conn.execute(
1176        "DELETE FROM blocked_peers WHERE fingerprint = ?1",
1177        params![fingerprint],
1178    )?;
1179    Ok(())
1180}
1181
1182// =========================================================================
1183// Peer profiles (huddle 0.5)
1184// =========================================================================
1185
1186/// Upsert the cached username for a peer iff the incoming `updated_at` is
1187/// strictly newer than what we have stored — last-write-wins on the
1188/// sender's monotonic ms. A None username here means the peer cleared
1189/// their name; render as `[anonymous]`.
1190pub fn upsert_peer_profile(
1191    db: &Db,
1192    fingerprint: &str,
1193    username: Option<&str>,
1194    updated_at: i64,
1195) -> Result<()> {
1196    let conn = db.lock().unwrap();
1197    conn.execute(
1198        "INSERT INTO peer_profiles (fingerprint, username, updated_at)
1199         VALUES (?1, ?2, ?3)
1200         ON CONFLICT(fingerprint) DO UPDATE SET
1201            username   = excluded.username,
1202            updated_at = excluded.updated_at
1203         WHERE excluded.updated_at > peer_profiles.updated_at",
1204        params![fingerprint, username, updated_at],
1205    )?;
1206    Ok(())
1207}
1208
1209/// Cached username for a peer if we've ever seen a signed ProfileUpdate
1210/// from them. Returns None for unknown peers and for peers who set
1211/// `username = None` (explicit anonymous) — caller renders `[anonymous]`.
1212pub fn get_peer_username(db: &Db, fingerprint: &str) -> Result<Option<String>> {
1213    let conn = db.lock().unwrap();
1214    let mut stmt = conn.prepare(
1215        "SELECT username FROM peer_profiles WHERE fingerprint = ?1",
1216    )?;
1217    let mut rows = stmt.query(params![fingerprint])?;
1218    if let Some(row) = rows.next()? {
1219        Ok(row.get::<_, Option<String>>(0)?)
1220    } else {
1221        Ok(None)
1222    }
1223}
1224
1225/// huddle 0.5.1: every fingerprint that has broadcast the given
1226/// username via a signed ProfileUpdate. Multiple matches are possible
1227/// — usernames aren't unique — so the "add by username" flow asks
1228/// the user to disambiguate via HD- ID when this returns > 1.
1229pub fn find_peers_by_username(db: &Db, username: &str) -> Result<Vec<String>> {
1230    let conn = db.lock().unwrap();
1231    let mut stmt = conn.prepare(
1232        "SELECT fingerprint FROM peer_profiles WHERE username = ?1",
1233    )?;
1234    let rows = stmt.query_map(params![username], |row| row.get::<_, String>(0))?;
1235    let mut out = Vec::new();
1236    for r in rows {
1237        out.push(r?);
1238    }
1239    Ok(out)
1240}
1241
1242// =========================================================================
1243// Room attachments
1244// =========================================================================
1245
1246/// Lifecycle of a file transfer card.
1247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1248pub enum AttachmentStatus {
1249    Offered,
1250    Downloading,
1251    Ready,
1252    Saved,
1253    Failed,
1254    Cancelled,
1255}
1256
1257impl AttachmentStatus {
1258    pub fn as_str(self) -> &'static str {
1259        match self {
1260            Self::Offered => "offered",
1261            Self::Downloading => "downloading",
1262            Self::Ready => "ready",
1263            Self::Saved => "saved",
1264            Self::Failed => "failed",
1265            Self::Cancelled => "cancelled",
1266        }
1267    }
1268    pub fn from_str(s: &str) -> Option<Self> {
1269        Some(match s {
1270            "offered" => Self::Offered,
1271            "downloading" => Self::Downloading,
1272            "ready" => Self::Ready,
1273            "saved" => Self::Saved,
1274            "failed" => Self::Failed,
1275            "cancelled" => Self::Cancelled,
1276            _ => return None,
1277        })
1278    }
1279}
1280
1281#[derive(Debug, Clone)]
1282pub struct StoredAttachment {
1283    pub id: i64,
1284    pub room_id: String,
1285    pub message_id: Option<i64>,
1286    pub sender_fingerprint: String,
1287    pub file_id: String,
1288    pub name: String,
1289    pub mime: Option<String>,
1290    pub size_bytes: i64,
1291    pub status: AttachmentStatus,
1292    pub cache_path: Option<String>,
1293    pub saved_path: Option<String>,
1294    pub error: Option<String>,
1295    pub encrypted: bool,
1296    pub wrapped_key: Option<String>,
1297    pub nonce: Option<String>,
1298    pub megolm_session_id: Option<String>,
1299    /// SHA-256 of the plaintext (hex), for encrypted attachments. Bound
1300    /// as AEAD associated data so the wrapped key + nonce + ciphertext
1301    /// can't be replayed against different content.
1302    pub content_hash: Option<String>,
1303    pub created_at: i64,
1304}
1305
1306/// Insert (or update on file_id collision within the same room).
1307pub fn upsert_attachment(db: &Db, a: &StoredAttachment) -> Result<()> {
1308    let conn = db.lock().unwrap();
1309    conn.execute(
1310        "INSERT INTO room_attachments
1311            (room_id, message_id, sender_fingerprint, file_id, name, mime,
1312             size_bytes, status, cache_path, saved_path, error,
1313             encrypted, wrapped_key, nonce, megolm_session_id, created_at,
1314             content_hash)
1315         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
1316         ON CONFLICT(room_id, file_id) DO UPDATE SET
1317            name = excluded.name,
1318            mime = excluded.mime,
1319            size_bytes = excluded.size_bytes,
1320            -- Don't downgrade a more advanced status.
1321            status = CASE
1322                WHEN room_attachments.status IN ('saved','ready')
1323                     AND excluded.status IN ('offered','downloading')
1324                THEN room_attachments.status
1325                ELSE excluded.status
1326            END,
1327            cache_path = COALESCE(excluded.cache_path, room_attachments.cache_path),
1328            saved_path = COALESCE(excluded.saved_path, room_attachments.saved_path),
1329            error      = excluded.error,
1330            wrapped_key = COALESCE(excluded.wrapped_key, room_attachments.wrapped_key),
1331            nonce       = COALESCE(excluded.nonce, room_attachments.nonce),
1332            megolm_session_id = COALESCE(excluded.megolm_session_id, room_attachments.megolm_session_id),
1333            content_hash = COALESCE(excluded.content_hash, room_attachments.content_hash)",
1334        params![
1335            a.room_id,
1336            a.message_id,
1337            a.sender_fingerprint,
1338            a.file_id,
1339            a.name,
1340            a.mime,
1341            a.size_bytes,
1342            a.status.as_str(),
1343            a.cache_path,
1344            a.saved_path,
1345            a.error,
1346            a.encrypted as i64,
1347            a.wrapped_key,
1348            a.nonce,
1349            a.megolm_session_id,
1350            a.created_at,
1351            a.content_hash,
1352        ],
1353    )?;
1354    Ok(())
1355}
1356
1357fn row_to_attachment(row: &rusqlite::Row) -> rusqlite::Result<StoredAttachment> {
1358    let status_s: String = row.get(8)?;
1359    let status = AttachmentStatus::from_str(&status_s).unwrap_or(AttachmentStatus::Failed);
1360    Ok(StoredAttachment {
1361        id: row.get(0)?,
1362        room_id: row.get(1)?,
1363        message_id: row.get(2)?,
1364        sender_fingerprint: row.get(3)?,
1365        file_id: row.get(4)?,
1366        name: row.get(5)?,
1367        mime: row.get(6)?,
1368        size_bytes: row.get(7)?,
1369        status,
1370        cache_path: row.get(9)?,
1371        saved_path: row.get(10)?,
1372        error: row.get(11)?,
1373        encrypted: row.get::<_, i64>(12)? != 0,
1374        wrapped_key: row.get(13)?,
1375        nonce: row.get(14)?,
1376        megolm_session_id: row.get(15)?,
1377        created_at: row.get(16)?,
1378        content_hash: row.get(17)?,
1379    })
1380}
1381
1382pub fn get_attachment(db: &Db, room_id: &str, file_id: &str) -> Result<Option<StoredAttachment>> {
1383    let conn = db.lock().unwrap();
1384    let mut stmt = conn.prepare(
1385        "SELECT id, room_id, message_id, sender_fingerprint, file_id, name, mime,
1386                size_bytes, status, cache_path, saved_path, error,
1387                encrypted, wrapped_key, nonce, megolm_session_id, created_at,
1388                content_hash
1389         FROM room_attachments WHERE room_id = ?1 AND file_id = ?2",
1390    )?;
1391    let mut rows = stmt.query_map(params![room_id, file_id], row_to_attachment)?;
1392    match rows.next() {
1393        Some(r) => Ok(Some(r?)),
1394        None => Ok(None),
1395    }
1396}
1397
1398pub fn list_room_attachments(db: &Db, room_id: &str) -> Result<Vec<StoredAttachment>> {
1399    let conn = db.lock().unwrap();
1400    let mut stmt = conn.prepare(
1401        "SELECT id, room_id, message_id, sender_fingerprint, file_id, name, mime,
1402                size_bytes, status, cache_path, saved_path, error,
1403                encrypted, wrapped_key, nonce, megolm_session_id, created_at,
1404                content_hash
1405         FROM room_attachments WHERE room_id = ?1 ORDER BY created_at ASC",
1406    )?;
1407    let rows = stmt.query_map(params![room_id], row_to_attachment)?;
1408    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
1409}
1410
1411pub fn update_attachment_status(
1412    db: &Db,
1413    room_id: &str,
1414    file_id: &str,
1415    status: AttachmentStatus,
1416    error: Option<&str>,
1417) -> Result<()> {
1418    let conn = db.lock().unwrap();
1419    conn.execute(
1420        "UPDATE room_attachments SET status = ?1, error = ?2
1421         WHERE room_id = ?3 AND file_id = ?4",
1422        params![status.as_str(), error, room_id, file_id],
1423    )?;
1424    Ok(())
1425}
1426
1427pub fn update_attachment_paths(
1428    db: &Db,
1429    room_id: &str,
1430    file_id: &str,
1431    cache_path: Option<&str>,
1432    saved_path: Option<&str>,
1433) -> Result<()> {
1434    let conn = db.lock().unwrap();
1435    conn.execute(
1436        "UPDATE room_attachments
1437         SET cache_path = COALESCE(?1, cache_path),
1438             saved_path = COALESCE(?2, saved_path)
1439         WHERE room_id = ?3 AND file_id = ?4",
1440        params![cache_path, saved_path, room_id, file_id],
1441    )?;
1442    Ok(())
1443}
1444
1445pub fn delete_attachment(db: &Db, room_id: &str, file_id: &str) -> Result<()> {
1446    let conn = db.lock().unwrap();
1447    conn.execute(
1448        "DELETE FROM room_attachments WHERE room_id = ?1 AND file_id = ?2",
1449        params![room_id, file_id],
1450    )?;
1451    Ok(())
1452}
1453
1454#[cfg(test)]
1455mod tests {
1456    use super::*;
1457    use crate::storage::open_db_in_memory;
1458
1459    fn make_room(name: &str) -> StoredRoom {
1460        let creator_fp = "test-creator-fp";
1461        let created_at = 1000;
1462        StoredRoom {
1463            id: derive_room_id(creator_fp, name, created_at),
1464            name: name.into(),
1465            creator_fingerprint: creator_fp.into(),
1466            encrypted: false,
1467            passphrase_salt: None,
1468            created_at,
1469            last_active: None,
1470            kind: RoomKind::Group,
1471        }
1472    }
1473
1474    #[test]
1475    fn identity_round_trip() {
1476        let db = open_db_in_memory().unwrap();
1477        save_identity(&db, b"secret-bytes-32-chars-long-xxxxx", 1000).unwrap();
1478        let loaded = load_identity(&db).unwrap().unwrap();
1479        assert_eq!(loaded.ed25519_secret, b"secret-bytes-32-chars-long-xxxxx");
1480        assert_eq!(loaded.created_at, 1000);
1481    }
1482
1483    #[test]
1484    fn room_id_is_deterministic() {
1485        let id1 = derive_room_id("creator-fp", "test-room", 1000);
1486        let id2 = derive_room_id("creator-fp", "test-room", 1000);
1487        assert_eq!(id1, id2);
1488        assert_eq!(id1.len(), 32); // 16 bytes hex-encoded
1489    }
1490
1491    #[test]
1492    fn room_id_differs_with_inputs() {
1493        let id1 = derive_room_id("creator-a", "test", 1000);
1494        let id2 = derive_room_id("creator-b", "test", 1000);
1495        let id3 = derive_room_id("creator-a", "test", 1001);
1496        assert_ne!(id1, id2);
1497        assert_ne!(id1, id3);
1498    }
1499
1500    #[test]
1501    fn room_insert_and_get() {
1502        let db = open_db_in_memory().unwrap();
1503        let room = make_room("lunch-talk");
1504        insert_room(&db, &room).unwrap();
1505        let loaded = get_room(&db, &room.id).unwrap().unwrap();
1506        assert_eq!(loaded.name, "lunch-talk");
1507        assert!(!loaded.encrypted);
1508    }
1509
1510    #[test]
1511    fn room_list_orders_by_last_active() {
1512        let db = open_db_in_memory().unwrap();
1513        let mut a = make_room("alpha");
1514        a.last_active = Some(100);
1515        let mut b = make_room("beta");
1516        b.last_active = Some(200);
1517        insert_room(&db, &a).unwrap();
1518        insert_room(&db, &b).unwrap();
1519        let rooms = list_rooms(&db).unwrap();
1520        assert_eq!(rooms[0].name, "beta");
1521        assert_eq!(rooms[1].name, "alpha");
1522    }
1523
1524    #[test]
1525    fn room_member_upsert() {
1526        let db = open_db_in_memory().unwrap();
1527        let room = make_room("r");
1528        insert_room(&db, &room).unwrap();
1529
1530        upsert_room_member(
1531            &db,
1532            &StoredRoomMember {
1533                room_id: room.id.clone(),
1534                peer_id: "peer-x".into(),
1535                fingerprint: "fp-x".into(),
1536                last_seen: Some(500),
1537                verified: false,
1538                ed25519_pubkey: None,
1539                role: "member".into(),
1540            },
1541        )
1542        .unwrap();
1543        let members = list_room_members(&db, &room.id).unwrap();
1544        assert_eq!(members.len(), 1);
1545        assert_eq!(members[0].fingerprint, "fp-x");
1546        assert!(!members[0].verified);
1547    }
1548
1549    #[test]
1550    fn set_and_query_verified() {
1551        let db = open_db_in_memory().unwrap();
1552        let room = make_room("r");
1553        insert_room(&db, &room).unwrap();
1554        upsert_room_member(
1555            &db,
1556            &StoredRoomMember {
1557                room_id: room.id.clone(),
1558                peer_id: "p1".into(),
1559                fingerprint: "fp-1".into(),
1560                last_seen: None,
1561                verified: false,
1562                ed25519_pubkey: None,
1563                role: "member".into(),
1564            },
1565        )
1566        .unwrap();
1567        set_member_verified(&db, &room.id, "fp-1", true).unwrap();
1568        let verified = list_verified_fingerprints(&db, &room.id).unwrap();
1569        assert_eq!(verified, vec!["fp-1".to_string()]);
1570        let m = list_room_members(&db, &room.id).unwrap();
1571        assert!(m[0].verified);
1572    }
1573
1574    #[test]
1575    fn megolm_session_round_trip() {
1576        let db = open_db_in_memory().unwrap();
1577        let room = make_room("r");
1578        insert_room(&db, &room).unwrap();
1579
1580        let session = StoredMegolmSession {
1581            room_id: room.id.clone(),
1582            sender_fingerprint: "fp-sender".into(),
1583            session_id: "session-1".into(),
1584            session_data: vec![1, 2, 3, 4],
1585            is_outbound: true,
1586            created_at: 100,
1587        };
1588        save_megolm_session(&db, &session).unwrap();
1589        let loaded = load_megolm_sessions_for_room(&db, &room.id).unwrap();
1590        assert_eq!(loaded.len(), 1);
1591        assert_eq!(loaded[0].session_data, vec![1, 2, 3, 4]);
1592        assert!(loaded[0].is_outbound);
1593    }
1594
1595    fn make_attachment(room_id: &str, file_id: &str, name: &str) -> StoredAttachment {
1596        StoredAttachment {
1597            id: 0,
1598            room_id: room_id.into(),
1599            message_id: None,
1600            sender_fingerprint: "sender-fp".into(),
1601            file_id: file_id.into(),
1602            name: name.into(),
1603            mime: Some("image/png".into()),
1604            size_bytes: 1234,
1605            status: AttachmentStatus::Offered,
1606            cache_path: None,
1607            saved_path: None,
1608            error: None,
1609            encrypted: false,
1610            wrapped_key: None,
1611            nonce: None,
1612            megolm_session_id: None,
1613            content_hash: None,
1614            created_at: 100,
1615        }
1616    }
1617
1618    #[test]
1619    fn attachment_upsert_and_get() {
1620        let db = open_db_in_memory().unwrap();
1621        let room = make_room("r");
1622        insert_room(&db, &room).unwrap();
1623        let a = make_attachment(&room.id, "file-abc", "photo.png");
1624        upsert_attachment(&db, &a).unwrap();
1625
1626        let loaded = get_attachment(&db, &room.id, "file-abc").unwrap().unwrap();
1627        assert_eq!(loaded.name, "photo.png");
1628        assert_eq!(loaded.status, AttachmentStatus::Offered);
1629        assert_eq!(loaded.size_bytes, 1234);
1630    }
1631
1632    #[test]
1633    fn attachment_status_transitions() {
1634        let db = open_db_in_memory().unwrap();
1635        let room = make_room("r");
1636        insert_room(&db, &room).unwrap();
1637        let a = make_attachment(&room.id, "fid", "f.bin");
1638        upsert_attachment(&db, &a).unwrap();
1639
1640        update_attachment_status(&db, &room.id, "fid", AttachmentStatus::Downloading, None)
1641            .unwrap();
1642        assert_eq!(
1643            get_attachment(&db, &room.id, "fid")
1644                .unwrap()
1645                .unwrap()
1646                .status,
1647            AttachmentStatus::Downloading
1648        );
1649
1650        update_attachment_status(&db, &room.id, "fid", AttachmentStatus::Ready, None).unwrap();
1651        update_attachment_paths(
1652            &db,
1653            &room.id,
1654            "fid",
1655            Some("/cache/fid"),
1656            Some("/Downloads/f.bin"),
1657        )
1658        .unwrap();
1659        let loaded = get_attachment(&db, &room.id, "fid").unwrap().unwrap();
1660        assert_eq!(loaded.status, AttachmentStatus::Ready);
1661        assert_eq!(loaded.cache_path.as_deref(), Some("/cache/fid"));
1662        assert_eq!(loaded.saved_path.as_deref(), Some("/Downloads/f.bin"));
1663    }
1664
1665    #[test]
1666    fn upsert_does_not_downgrade_status() {
1667        let db = open_db_in_memory().unwrap();
1668        let room = make_room("r");
1669        insert_room(&db, &room).unwrap();
1670        let mut a = make_attachment(&room.id, "fid", "f.bin");
1671        a.status = AttachmentStatus::Saved;
1672        upsert_attachment(&db, &a).unwrap();
1673
1674        a.status = AttachmentStatus::Offered;
1675        upsert_attachment(&db, &a).unwrap();
1676        assert_eq!(
1677            get_attachment(&db, &room.id, "fid")
1678                .unwrap()
1679                .unwrap()
1680                .status,
1681            AttachmentStatus::Saved
1682        );
1683    }
1684
1685    #[test]
1686    fn list_attachments_for_room() {
1687        let db = open_db_in_memory().unwrap();
1688        let room = make_room("r");
1689        insert_room(&db, &room).unwrap();
1690        upsert_attachment(&db, &make_attachment(&room.id, "fid-a", "a.bin")).unwrap();
1691        upsert_attachment(&db, &make_attachment(&room.id, "fid-b", "b.bin")).unwrap();
1692        let list = list_room_attachments(&db, &room.id).unwrap();
1693        assert_eq!(list.len(), 2);
1694        assert_eq!(list[0].file_id, "fid-a");
1695        assert_eq!(list[1].file_id, "fid-b");
1696    }
1697
1698    #[test]
1699    fn attachment_status_string_round_trip() {
1700        for &s in &[
1701            AttachmentStatus::Offered,
1702            AttachmentStatus::Downloading,
1703            AttachmentStatus::Ready,
1704            AttachmentStatus::Saved,
1705            AttachmentStatus::Failed,
1706            AttachmentStatus::Cancelled,
1707        ] {
1708            assert_eq!(AttachmentStatus::from_str(s.as_str()), Some(s));
1709        }
1710    }
1711
1712    #[test]
1713    fn room_messages_query_returns_chronological() {
1714        let db = open_db_in_memory().unwrap();
1715        let room = make_room("r");
1716        insert_room(&db, &room).unwrap();
1717
1718        insert_room_message(&db, &room.id, "alice-fp", "in", "hi", 100).unwrap();
1719        insert_room_message(&db, &room.id, "me-fp", "out", "hello", 101).unwrap();
1720        insert_room_message(&db, &room.id, "alice-fp", "in", "bye", 102).unwrap();
1721
1722        let msgs = get_room_messages(&db, &room.id, 10).unwrap();
1723        assert_eq!(msgs.len(), 3);
1724        assert_eq!(msgs[0].body, "hi");
1725        assert_eq!(msgs[1].body, "hello");
1726        assert_eq!(msgs[2].body, "bye");
1727    }
1728
1729    #[test]
1730    fn search_escapes_like_wildcards() {
1731        let db = open_db_in_memory().unwrap();
1732        let room = make_room("r");
1733        insert_room(&db, &room).unwrap();
1734        insert_room_message(&db, &room.id, "fp", "in", "literal percent: 50%", 100).unwrap();
1735        insert_room_message(&db, &room.id, "fp", "in", "no special chars here", 101).unwrap();
1736
1737        // "%" must match a literal "%", not act as a wildcard-matches-all.
1738        let pct = search_room_messages(&db, &room.id, "%", 10).unwrap();
1739        assert_eq!(pct.len(), 1);
1740        assert!(pct[0].body.contains("50%"));
1741
1742        // "_" likewise must not match an arbitrary single character.
1743        let underscore = search_room_messages(&db, &room.id, "_", 10).unwrap();
1744        assert!(underscore.is_empty());
1745    }
1746}