1use rusqlite::params;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4
5use crate::error::Result;
6use crate::storage::Db;
7
8#[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
58pub 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#[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 pub kind: RoomKind,
136}
137
138pub 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
149pub 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
223pub 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#[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 pub ed25519_pubkey: Option<String>,
299 pub role: String,
304}
305
306pub 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
339pub 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
373pub 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
385pub 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
397pub 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
439pub 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
451pub 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
488pub 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#[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#[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
598pub fn search_room_messages(
602 db: &Db,
603 room_id: &str,
604 query: &str,
605 limit: i64,
606) -> Result<Vec<StoredRoomMessage>> {
607 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#[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 pub fingerprint: Option<String>,
669 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
732pub 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#[derive(Debug, Clone)]
755pub struct PendingFriendRequest {
756 pub fingerprint: String,
757 pub address: String,
758 pub peer_id: String,
759 pub received_at: i64,
760}
761
762pub const PENDING_FRIEND_REQUEST_TTL_SECS: i64 = 3 * 24 * 60 * 60;
767
768pub fn upsert_pending_friend_request(db: &Db, req: &PendingFriendRequest) -> Result<()> {
769 let conn = db.lock().unwrap();
770 conn.execute(
771 "INSERT INTO pending_friend_requests (fingerprint, address, peer_id, received_at)
772 VALUES (?1, ?2, ?3, ?4)
773 ON CONFLICT(fingerprint, address) DO UPDATE SET
774 peer_id = excluded.peer_id,
775 received_at = excluded.received_at",
776 params![req.fingerprint, req.address, req.peer_id, req.received_at],
777 )?;
778 Ok(())
779}
780
781pub fn list_pending_friend_requests(db: &Db) -> Result<Vec<PendingFriendRequest>> {
782 let conn = db.lock().unwrap();
783 let mut stmt = conn.prepare(
784 "SELECT fingerprint, address, peer_id, received_at
785 FROM pending_friend_requests
786 ORDER BY received_at DESC",
787 )?;
788 let rows = stmt.query_map([], |row| {
789 Ok(PendingFriendRequest {
790 fingerprint: row.get(0)?,
791 address: row.get(1)?,
792 peer_id: row.get(2)?,
793 received_at: row.get(3)?,
794 })
795 })?;
796 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
797}
798
799pub fn delete_pending_friend_requests_for_fp(db: &Db, fingerprint: &str) -> Result<()> {
804 let conn = db.lock().unwrap();
805 conn.execute(
806 "DELETE FROM pending_friend_requests WHERE fingerprint = ?1",
807 params![fingerprint],
808 )?;
809 Ok(())
810}
811
812pub fn cleanup_expired_pending_friend_requests(db: &Db, now: i64) -> Result<usize> {
816 let cutoff = now.saturating_sub(PENDING_FRIEND_REQUEST_TTL_SECS);
820 let conn = db.lock().unwrap();
821 let removed = conn.execute(
822 "DELETE FROM pending_friend_requests WHERE received_at < ?1",
823 params![cutoff],
824 )?;
825 Ok(removed)
826}
827
828pub fn block_peer(db: &Db, fingerprint: &str, now: i64) -> Result<()> {
832 let conn = db.lock().unwrap();
833 conn.execute(
834 "INSERT INTO blocked_peers (fingerprint, blocked_at) VALUES (?1, ?2)
835 ON CONFLICT(fingerprint) DO UPDATE SET blocked_at = excluded.blocked_at",
836 params![fingerprint, now],
837 )?;
838 Ok(())
839}
840
841pub fn get_setting(db: &Db, key: &str) -> Result<Option<String>> {
844 let conn = db.lock().unwrap();
845 let mut stmt = conn.prepare("SELECT value FROM app_settings WHERE key = ?1")?;
846 let row = stmt
847 .query_row(params![key], |r| r.get::<_, String>(0))
848 .ok();
849 Ok(row)
850}
851
852pub fn set_setting(db: &Db, key: &str, value: &str) -> Result<()> {
853 let conn = db.lock().unwrap();
854 conn.execute(
855 "INSERT INTO app_settings (key, value) VALUES (?1, ?2)
856 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
857 params![key, value],
858 )?;
859 Ok(())
860}
861
862pub fn get_room_verified_only(db: &Db, room_id: &str) -> Result<bool> {
864 let conn = db.lock().unwrap();
865 let v: i64 = conn
866 .query_row(
867 "SELECT verified_only_join FROM rooms WHERE id = ?1",
868 params![room_id],
869 |r| r.get(0),
870 )
871 .unwrap_or(0);
872 Ok(v != 0)
873}
874
875pub fn set_room_verified_only(db: &Db, room_id: &str, on: bool) -> Result<()> {
876 let conn = db.lock().unwrap();
877 conn.execute(
878 "UPDATE rooms SET verified_only_join = ?1 WHERE id = ?2",
879 params![on as i64, room_id],
880 )?;
881 Ok(())
882}
883
884pub fn add_verified_peer(db: &Db, fingerprint: &str, verified_at: i64) -> Result<()> {
888 let conn = db.lock().unwrap();
889 conn.execute(
890 "INSERT INTO verified_peers (fingerprint, verified_at) VALUES (?1, ?2)
891 ON CONFLICT(fingerprint) DO UPDATE SET verified_at = excluded.verified_at",
892 params![fingerprint, verified_at],
893 )?;
894 Ok(())
895}
896
897pub fn is_globally_verified(db: &Db, fingerprint: &str) -> Result<bool> {
901 let conn = db.lock().unwrap();
902 let count: i64 = conn
903 .query_row(
904 "SELECT COUNT(*) FROM verified_peers WHERE fingerprint = ?1",
905 params![fingerprint],
906 |r| r.get(0),
907 )
908 .unwrap_or(0);
909 Ok(count > 0)
910}
911
912pub fn list_verified_peers(db: &Db) -> Result<Vec<String>> {
915 let conn = db.lock().unwrap();
916 let mut stmt =
917 conn.prepare("SELECT fingerprint FROM verified_peers ORDER BY verified_at DESC")?;
918 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
919 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
920}
921
922pub fn is_onboarding_seen(db: &Db) -> Result<bool> {
924 let conn = db.lock().unwrap();
925 let v: i64 = conn
926 .query_row(
927 "SELECT onboarding_seen FROM identity WHERE id = 1",
928 [],
929 |r| r.get(0),
930 )
931 .unwrap_or(0);
932 Ok(v != 0)
933}
934
935pub fn mark_onboarding_seen(db: &Db) -> Result<()> {
936 let conn = db.lock().unwrap();
937 conn.execute(
938 "UPDATE identity SET onboarding_seen = 1 WHERE id = 1",
939 [],
940 )?;
941 Ok(())
942}
943
944pub fn get_last_seen_onboarding_version(db: &Db) -> Result<Option<String>> {
950 get_setting(db, "last_seen_onboarding_version")
951}
952
953pub fn set_last_seen_onboarding_version(db: &Db, version: &str) -> Result<()> {
954 set_setting(db, "last_seen_onboarding_version", version)
955}
956
957pub fn get_update_check_enabled(db: &Db) -> Result<Option<bool>> {
961 Ok(get_setting(db, "update_check_enabled")?
962 .map(|v| v == "1" || v.eq_ignore_ascii_case("true")))
963}
964
965pub fn set_update_check_enabled(db: &Db, enabled: bool) -> Result<()> {
966 set_setting(db, "update_check_enabled", if enabled { "1" } else { "0" })
967}
968
969pub fn is_peer_blocked(db: &Db, fingerprint: &str) -> Result<bool> {
970 let conn = db.lock().unwrap();
971 let count: i64 = conn
972 .query_row(
973 "SELECT COUNT(*) FROM blocked_peers WHERE fingerprint = ?1",
974 params![fingerprint],
975 |r| r.get(0),
976 )
977 .unwrap_or(0);
978 Ok(count > 0)
979}
980
981pub fn list_blocked_peers(db: &Db) -> Result<Vec<String>> {
985 let conn = db.lock().unwrap();
986 let mut stmt = conn.prepare(
987 "SELECT fingerprint FROM blocked_peers ORDER BY blocked_at DESC",
988 )?;
989 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
990 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
991}
992
993pub fn unblock_peer(db: &Db, fingerprint: &str) -> Result<()> {
997 let conn = db.lock().unwrap();
998 conn.execute(
999 "DELETE FROM blocked_peers WHERE fingerprint = ?1",
1000 params![fingerprint],
1001 )?;
1002 Ok(())
1003}
1004
1005pub fn upsert_peer_profile(
1014 db: &Db,
1015 fingerprint: &str,
1016 username: Option<&str>,
1017 updated_at: i64,
1018) -> Result<()> {
1019 let conn = db.lock().unwrap();
1020 conn.execute(
1021 "INSERT INTO peer_profiles (fingerprint, username, updated_at)
1022 VALUES (?1, ?2, ?3)
1023 ON CONFLICT(fingerprint) DO UPDATE SET
1024 username = excluded.username,
1025 updated_at = excluded.updated_at
1026 WHERE excluded.updated_at > peer_profiles.updated_at",
1027 params![fingerprint, username, updated_at],
1028 )?;
1029 Ok(())
1030}
1031
1032pub fn get_peer_username(db: &Db, fingerprint: &str) -> Result<Option<String>> {
1036 let conn = db.lock().unwrap();
1037 let mut stmt = conn.prepare(
1038 "SELECT username FROM peer_profiles WHERE fingerprint = ?1",
1039 )?;
1040 let mut rows = stmt.query(params![fingerprint])?;
1041 if let Some(row) = rows.next()? {
1042 Ok(row.get::<_, Option<String>>(0)?)
1043 } else {
1044 Ok(None)
1045 }
1046}
1047
1048pub fn find_peers_by_username(db: &Db, username: &str) -> Result<Vec<String>> {
1053 let conn = db.lock().unwrap();
1054 let mut stmt = conn.prepare(
1055 "SELECT fingerprint FROM peer_profiles WHERE username = ?1",
1056 )?;
1057 let rows = stmt.query_map(params![username], |row| row.get::<_, String>(0))?;
1058 let mut out = Vec::new();
1059 for r in rows {
1060 out.push(r?);
1061 }
1062 Ok(out)
1063}
1064
1065#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1071pub enum AttachmentStatus {
1072 Offered,
1073 Downloading,
1074 Ready,
1075 Saved,
1076 Failed,
1077 Cancelled,
1078}
1079
1080impl AttachmentStatus {
1081 pub fn as_str(self) -> &'static str {
1082 match self {
1083 Self::Offered => "offered",
1084 Self::Downloading => "downloading",
1085 Self::Ready => "ready",
1086 Self::Saved => "saved",
1087 Self::Failed => "failed",
1088 Self::Cancelled => "cancelled",
1089 }
1090 }
1091 pub fn from_str(s: &str) -> Option<Self> {
1092 Some(match s {
1093 "offered" => Self::Offered,
1094 "downloading" => Self::Downloading,
1095 "ready" => Self::Ready,
1096 "saved" => Self::Saved,
1097 "failed" => Self::Failed,
1098 "cancelled" => Self::Cancelled,
1099 _ => return None,
1100 })
1101 }
1102}
1103
1104#[derive(Debug, Clone)]
1105pub struct StoredAttachment {
1106 pub id: i64,
1107 pub room_id: String,
1108 pub message_id: Option<i64>,
1109 pub sender_fingerprint: String,
1110 pub file_id: String,
1111 pub name: String,
1112 pub mime: Option<String>,
1113 pub size_bytes: i64,
1114 pub status: AttachmentStatus,
1115 pub cache_path: Option<String>,
1116 pub saved_path: Option<String>,
1117 pub error: Option<String>,
1118 pub encrypted: bool,
1119 pub wrapped_key: Option<String>,
1120 pub nonce: Option<String>,
1121 pub megolm_session_id: Option<String>,
1122 pub content_hash: Option<String>,
1126 pub created_at: i64,
1127}
1128
1129pub fn upsert_attachment(db: &Db, a: &StoredAttachment) -> Result<()> {
1131 let conn = db.lock().unwrap();
1132 conn.execute(
1133 "INSERT INTO room_attachments
1134 (room_id, message_id, sender_fingerprint, file_id, name, mime,
1135 size_bytes, status, cache_path, saved_path, error,
1136 encrypted, wrapped_key, nonce, megolm_session_id, created_at,
1137 content_hash)
1138 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
1139 ON CONFLICT(room_id, file_id) DO UPDATE SET
1140 name = excluded.name,
1141 mime = excluded.mime,
1142 size_bytes = excluded.size_bytes,
1143 -- Don't downgrade a more advanced status.
1144 status = CASE
1145 WHEN room_attachments.status IN ('saved','ready')
1146 AND excluded.status IN ('offered','downloading')
1147 THEN room_attachments.status
1148 ELSE excluded.status
1149 END,
1150 cache_path = COALESCE(excluded.cache_path, room_attachments.cache_path),
1151 saved_path = COALESCE(excluded.saved_path, room_attachments.saved_path),
1152 error = excluded.error,
1153 wrapped_key = COALESCE(excluded.wrapped_key, room_attachments.wrapped_key),
1154 nonce = COALESCE(excluded.nonce, room_attachments.nonce),
1155 megolm_session_id = COALESCE(excluded.megolm_session_id, room_attachments.megolm_session_id),
1156 content_hash = COALESCE(excluded.content_hash, room_attachments.content_hash)",
1157 params![
1158 a.room_id,
1159 a.message_id,
1160 a.sender_fingerprint,
1161 a.file_id,
1162 a.name,
1163 a.mime,
1164 a.size_bytes,
1165 a.status.as_str(),
1166 a.cache_path,
1167 a.saved_path,
1168 a.error,
1169 a.encrypted as i64,
1170 a.wrapped_key,
1171 a.nonce,
1172 a.megolm_session_id,
1173 a.created_at,
1174 a.content_hash,
1175 ],
1176 )?;
1177 Ok(())
1178}
1179
1180fn row_to_attachment(row: &rusqlite::Row) -> rusqlite::Result<StoredAttachment> {
1181 let status_s: String = row.get(8)?;
1182 let status = AttachmentStatus::from_str(&status_s).unwrap_or(AttachmentStatus::Failed);
1183 Ok(StoredAttachment {
1184 id: row.get(0)?,
1185 room_id: row.get(1)?,
1186 message_id: row.get(2)?,
1187 sender_fingerprint: row.get(3)?,
1188 file_id: row.get(4)?,
1189 name: row.get(5)?,
1190 mime: row.get(6)?,
1191 size_bytes: row.get(7)?,
1192 status,
1193 cache_path: row.get(9)?,
1194 saved_path: row.get(10)?,
1195 error: row.get(11)?,
1196 encrypted: row.get::<_, i64>(12)? != 0,
1197 wrapped_key: row.get(13)?,
1198 nonce: row.get(14)?,
1199 megolm_session_id: row.get(15)?,
1200 created_at: row.get(16)?,
1201 content_hash: row.get(17)?,
1202 })
1203}
1204
1205pub fn get_attachment(db: &Db, room_id: &str, file_id: &str) -> Result<Option<StoredAttachment>> {
1206 let conn = db.lock().unwrap();
1207 let mut stmt = conn.prepare(
1208 "SELECT id, room_id, message_id, sender_fingerprint, file_id, name, mime,
1209 size_bytes, status, cache_path, saved_path, error,
1210 encrypted, wrapped_key, nonce, megolm_session_id, created_at,
1211 content_hash
1212 FROM room_attachments WHERE room_id = ?1 AND file_id = ?2",
1213 )?;
1214 let mut rows = stmt.query_map(params![room_id, file_id], row_to_attachment)?;
1215 match rows.next() {
1216 Some(r) => Ok(Some(r?)),
1217 None => Ok(None),
1218 }
1219}
1220
1221pub fn list_room_attachments(db: &Db, room_id: &str) -> Result<Vec<StoredAttachment>> {
1222 let conn = db.lock().unwrap();
1223 let mut stmt = conn.prepare(
1224 "SELECT id, room_id, message_id, sender_fingerprint, file_id, name, mime,
1225 size_bytes, status, cache_path, saved_path, error,
1226 encrypted, wrapped_key, nonce, megolm_session_id, created_at,
1227 content_hash
1228 FROM room_attachments WHERE room_id = ?1 ORDER BY created_at ASC",
1229 )?;
1230 let rows = stmt.query_map(params![room_id], row_to_attachment)?;
1231 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
1232}
1233
1234pub fn update_attachment_status(
1235 db: &Db,
1236 room_id: &str,
1237 file_id: &str,
1238 status: AttachmentStatus,
1239 error: Option<&str>,
1240) -> Result<()> {
1241 let conn = db.lock().unwrap();
1242 conn.execute(
1243 "UPDATE room_attachments SET status = ?1, error = ?2
1244 WHERE room_id = ?3 AND file_id = ?4",
1245 params![status.as_str(), error, room_id, file_id],
1246 )?;
1247 Ok(())
1248}
1249
1250pub fn update_attachment_paths(
1251 db: &Db,
1252 room_id: &str,
1253 file_id: &str,
1254 cache_path: Option<&str>,
1255 saved_path: Option<&str>,
1256) -> Result<()> {
1257 let conn = db.lock().unwrap();
1258 conn.execute(
1259 "UPDATE room_attachments
1260 SET cache_path = COALESCE(?1, cache_path),
1261 saved_path = COALESCE(?2, saved_path)
1262 WHERE room_id = ?3 AND file_id = ?4",
1263 params![cache_path, saved_path, room_id, file_id],
1264 )?;
1265 Ok(())
1266}
1267
1268pub fn delete_attachment(db: &Db, room_id: &str, file_id: &str) -> Result<()> {
1269 let conn = db.lock().unwrap();
1270 conn.execute(
1271 "DELETE FROM room_attachments WHERE room_id = ?1 AND file_id = ?2",
1272 params![room_id, file_id],
1273 )?;
1274 Ok(())
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279 use super::*;
1280 use crate::storage::open_db_in_memory;
1281
1282 fn make_room(name: &str) -> StoredRoom {
1283 let creator_fp = "test-creator-fp";
1284 let created_at = 1000;
1285 StoredRoom {
1286 id: derive_room_id(creator_fp, name, created_at),
1287 name: name.into(),
1288 creator_fingerprint: creator_fp.into(),
1289 encrypted: false,
1290 passphrase_salt: None,
1291 created_at,
1292 last_active: None,
1293 kind: RoomKind::Group,
1294 }
1295 }
1296
1297 #[test]
1298 fn identity_round_trip() {
1299 let db = open_db_in_memory().unwrap();
1300 save_identity(&db, b"secret-bytes-32-chars-long-xxxxx", 1000).unwrap();
1301 let loaded = load_identity(&db).unwrap().unwrap();
1302 assert_eq!(loaded.ed25519_secret, b"secret-bytes-32-chars-long-xxxxx");
1303 assert_eq!(loaded.created_at, 1000);
1304 }
1305
1306 #[test]
1307 fn room_id_is_deterministic() {
1308 let id1 = derive_room_id("creator-fp", "test-room", 1000);
1309 let id2 = derive_room_id("creator-fp", "test-room", 1000);
1310 assert_eq!(id1, id2);
1311 assert_eq!(id1.len(), 32); }
1313
1314 #[test]
1315 fn room_id_differs_with_inputs() {
1316 let id1 = derive_room_id("creator-a", "test", 1000);
1317 let id2 = derive_room_id("creator-b", "test", 1000);
1318 let id3 = derive_room_id("creator-a", "test", 1001);
1319 assert_ne!(id1, id2);
1320 assert_ne!(id1, id3);
1321 }
1322
1323 #[test]
1324 fn room_insert_and_get() {
1325 let db = open_db_in_memory().unwrap();
1326 let room = make_room("lunch-talk");
1327 insert_room(&db, &room).unwrap();
1328 let loaded = get_room(&db, &room.id).unwrap().unwrap();
1329 assert_eq!(loaded.name, "lunch-talk");
1330 assert!(!loaded.encrypted);
1331 }
1332
1333 #[test]
1334 fn room_list_orders_by_last_active() {
1335 let db = open_db_in_memory().unwrap();
1336 let mut a = make_room("alpha");
1337 a.last_active = Some(100);
1338 let mut b = make_room("beta");
1339 b.last_active = Some(200);
1340 insert_room(&db, &a).unwrap();
1341 insert_room(&db, &b).unwrap();
1342 let rooms = list_rooms(&db).unwrap();
1343 assert_eq!(rooms[0].name, "beta");
1344 assert_eq!(rooms[1].name, "alpha");
1345 }
1346
1347 #[test]
1348 fn room_member_upsert() {
1349 let db = open_db_in_memory().unwrap();
1350 let room = make_room("r");
1351 insert_room(&db, &room).unwrap();
1352
1353 upsert_room_member(
1354 &db,
1355 &StoredRoomMember {
1356 room_id: room.id.clone(),
1357 peer_id: "peer-x".into(),
1358 fingerprint: "fp-x".into(),
1359 last_seen: Some(500),
1360 verified: false,
1361 ed25519_pubkey: None,
1362 role: "member".into(),
1363 },
1364 )
1365 .unwrap();
1366 let members = list_room_members(&db, &room.id).unwrap();
1367 assert_eq!(members.len(), 1);
1368 assert_eq!(members[0].fingerprint, "fp-x");
1369 assert!(!members[0].verified);
1370 }
1371
1372 #[test]
1373 fn set_and_query_verified() {
1374 let db = open_db_in_memory().unwrap();
1375 let room = make_room("r");
1376 insert_room(&db, &room).unwrap();
1377 upsert_room_member(
1378 &db,
1379 &StoredRoomMember {
1380 room_id: room.id.clone(),
1381 peer_id: "p1".into(),
1382 fingerprint: "fp-1".into(),
1383 last_seen: None,
1384 verified: false,
1385 ed25519_pubkey: None,
1386 role: "member".into(),
1387 },
1388 )
1389 .unwrap();
1390 set_member_verified(&db, &room.id, "fp-1", true).unwrap();
1391 let verified = list_verified_fingerprints(&db, &room.id).unwrap();
1392 assert_eq!(verified, vec!["fp-1".to_string()]);
1393 let m = list_room_members(&db, &room.id).unwrap();
1394 assert!(m[0].verified);
1395 }
1396
1397 #[test]
1398 fn megolm_session_round_trip() {
1399 let db = open_db_in_memory().unwrap();
1400 let room = make_room("r");
1401 insert_room(&db, &room).unwrap();
1402
1403 let session = StoredMegolmSession {
1404 room_id: room.id.clone(),
1405 sender_fingerprint: "fp-sender".into(),
1406 session_id: "session-1".into(),
1407 session_data: vec![1, 2, 3, 4],
1408 is_outbound: true,
1409 created_at: 100,
1410 };
1411 save_megolm_session(&db, &session).unwrap();
1412 let loaded = load_megolm_sessions_for_room(&db, &room.id).unwrap();
1413 assert_eq!(loaded.len(), 1);
1414 assert_eq!(loaded[0].session_data, vec![1, 2, 3, 4]);
1415 assert!(loaded[0].is_outbound);
1416 }
1417
1418 fn make_attachment(room_id: &str, file_id: &str, name: &str) -> StoredAttachment {
1419 StoredAttachment {
1420 id: 0,
1421 room_id: room_id.into(),
1422 message_id: None,
1423 sender_fingerprint: "sender-fp".into(),
1424 file_id: file_id.into(),
1425 name: name.into(),
1426 mime: Some("image/png".into()),
1427 size_bytes: 1234,
1428 status: AttachmentStatus::Offered,
1429 cache_path: None,
1430 saved_path: None,
1431 error: None,
1432 encrypted: false,
1433 wrapped_key: None,
1434 nonce: None,
1435 megolm_session_id: None,
1436 content_hash: None,
1437 created_at: 100,
1438 }
1439 }
1440
1441 #[test]
1442 fn attachment_upsert_and_get() {
1443 let db = open_db_in_memory().unwrap();
1444 let room = make_room("r");
1445 insert_room(&db, &room).unwrap();
1446 let a = make_attachment(&room.id, "file-abc", "photo.png");
1447 upsert_attachment(&db, &a).unwrap();
1448
1449 let loaded = get_attachment(&db, &room.id, "file-abc").unwrap().unwrap();
1450 assert_eq!(loaded.name, "photo.png");
1451 assert_eq!(loaded.status, AttachmentStatus::Offered);
1452 assert_eq!(loaded.size_bytes, 1234);
1453 }
1454
1455 #[test]
1456 fn attachment_status_transitions() {
1457 let db = open_db_in_memory().unwrap();
1458 let room = make_room("r");
1459 insert_room(&db, &room).unwrap();
1460 let a = make_attachment(&room.id, "fid", "f.bin");
1461 upsert_attachment(&db, &a).unwrap();
1462
1463 update_attachment_status(&db, &room.id, "fid", AttachmentStatus::Downloading, None)
1464 .unwrap();
1465 assert_eq!(
1466 get_attachment(&db, &room.id, "fid")
1467 .unwrap()
1468 .unwrap()
1469 .status,
1470 AttachmentStatus::Downloading
1471 );
1472
1473 update_attachment_status(&db, &room.id, "fid", AttachmentStatus::Ready, None).unwrap();
1474 update_attachment_paths(
1475 &db,
1476 &room.id,
1477 "fid",
1478 Some("/cache/fid"),
1479 Some("/Downloads/f.bin"),
1480 )
1481 .unwrap();
1482 let loaded = get_attachment(&db, &room.id, "fid").unwrap().unwrap();
1483 assert_eq!(loaded.status, AttachmentStatus::Ready);
1484 assert_eq!(loaded.cache_path.as_deref(), Some("/cache/fid"));
1485 assert_eq!(loaded.saved_path.as_deref(), Some("/Downloads/f.bin"));
1486 }
1487
1488 #[test]
1489 fn upsert_does_not_downgrade_status() {
1490 let db = open_db_in_memory().unwrap();
1491 let room = make_room("r");
1492 insert_room(&db, &room).unwrap();
1493 let mut a = make_attachment(&room.id, "fid", "f.bin");
1494 a.status = AttachmentStatus::Saved;
1495 upsert_attachment(&db, &a).unwrap();
1496
1497 a.status = AttachmentStatus::Offered;
1498 upsert_attachment(&db, &a).unwrap();
1499 assert_eq!(
1500 get_attachment(&db, &room.id, "fid")
1501 .unwrap()
1502 .unwrap()
1503 .status,
1504 AttachmentStatus::Saved
1505 );
1506 }
1507
1508 #[test]
1509 fn list_attachments_for_room() {
1510 let db = open_db_in_memory().unwrap();
1511 let room = make_room("r");
1512 insert_room(&db, &room).unwrap();
1513 upsert_attachment(&db, &make_attachment(&room.id, "fid-a", "a.bin")).unwrap();
1514 upsert_attachment(&db, &make_attachment(&room.id, "fid-b", "b.bin")).unwrap();
1515 let list = list_room_attachments(&db, &room.id).unwrap();
1516 assert_eq!(list.len(), 2);
1517 assert_eq!(list[0].file_id, "fid-a");
1518 assert_eq!(list[1].file_id, "fid-b");
1519 }
1520
1521 #[test]
1522 fn attachment_status_string_round_trip() {
1523 for &s in &[
1524 AttachmentStatus::Offered,
1525 AttachmentStatus::Downloading,
1526 AttachmentStatus::Ready,
1527 AttachmentStatus::Saved,
1528 AttachmentStatus::Failed,
1529 AttachmentStatus::Cancelled,
1530 ] {
1531 assert_eq!(AttachmentStatus::from_str(s.as_str()), Some(s));
1532 }
1533 }
1534
1535 #[test]
1536 fn room_messages_query_returns_chronological() {
1537 let db = open_db_in_memory().unwrap();
1538 let room = make_room("r");
1539 insert_room(&db, &room).unwrap();
1540
1541 insert_room_message(&db, &room.id, "alice-fp", "in", "hi", 100).unwrap();
1542 insert_room_message(&db, &room.id, "me-fp", "out", "hello", 101).unwrap();
1543 insert_room_message(&db, &room.id, "alice-fp", "in", "bye", 102).unwrap();
1544
1545 let msgs = get_room_messages(&db, &room.id, 10).unwrap();
1546 assert_eq!(msgs.len(), 3);
1547 assert_eq!(msgs[0].body, "hi");
1548 assert_eq!(msgs[1].body, "hello");
1549 assert_eq!(msgs[2].body, "bye");
1550 }
1551
1552 #[test]
1553 fn search_escapes_like_wildcards() {
1554 let db = open_db_in_memory().unwrap();
1555 let room = make_room("r");
1556 insert_room(&db, &room).unwrap();
1557 insert_room_message(&db, &room.id, "fp", "in", "literal percent: 50%", 100).unwrap();
1558 insert_room_message(&db, &room.id, "fp", "in", "no special chars here", 101).unwrap();
1559
1560 let pct = search_room_messages(&db, &room.id, "%", 10).unwrap();
1562 assert_eq!(pct.len(), 1);
1563 assert!(pct[0].body.contains("50%"));
1564
1565 let underscore = search_room_messages(&db, &room.id, "_", 10).unwrap();
1567 assert!(underscore.is_empty());
1568 }
1569}