//! Sharing a note from a linked device (#5175). //! //! Shares belong to the server: who is on the instance and who a note is shared //! with are never kept here, so each call goes straight to the server over the //! device token. The one thing kept locally is the note's `shared` flag, set from //! the server's answer so the card's chip changes at once rather than at the next //! sync (which brings the same value). //! //! The server address and token come from the CALLER, read with //! `state::credentials` and the caller's seal. A stored token may be sealed //! (Android keeps it under a Keystore key), and reading it raw here sent `sealed:…` //! as the bearer token, so every share call was refused (#5381). use rusqlite::params; use super::client::{self, Directory, NoteShare, ShareTarget}; use super::state; use crate::local::Db; /// What an unlinked device says when asked to share. Sharing is between accounts on /// a server, so there is nothing to do offline and nothing worth queueing. pub const NEEDS_SERVER: &str = "Sharing is between people on a server. Link this device to one in Sync to share notes."; /// The link to share over, or [`NEEDS_SERVER`]. For a client that keeps its token /// plain, as the desktop does; one with a seal reads `state::credentials` itself. pub fn stored_link(db: &Db) -> Result<(String, String), String> { let conn = db.conn()?; let link = state::credentials(&conn, None).map_err(|e| e.to_string())?; link.ok_or_else(|| NEEDS_SERVER.to_string()) } /// Set the local note's `shared` flag from the server's list of its shares. Not a /// local edit, so it leaves `dirty` and `updated_at` alone. fn mark_shared(db: &Db, note_id: &str, shares: &[NoteShare]) -> Result<(), String> { let conn = db.conn()?; conn.execute( "UPDATE notes SET shared = ?2 WHERE id = ?1 AND permission = 'owner'", params![note_id, !shares.is_empty()], ) .map_err(|e| e.to_string())?; Ok(()) } pub async fn directory(url: &str, token: &str) -> Result { client::directory(url, token).await } pub async fn list( db: &Db, url: &str, token: &str, note_id: &str, ) -> Result, String> { let shares = client::list_shares(url, token, note_id).await?; mark_shared(db, note_id, &shares)?; Ok(shares) } pub async fn share( db: &Db, url: &str, token: &str, note_id: &str, target: &ShareTarget, permission: &str, ) -> Result, String> { let shares = client::share_note(url, token, note_id, target, permission).await?; mark_shared(db, note_id, &shares)?; Ok(shares) } pub async fn unshare( db: &Db, url: &str, token: &str, note_id: &str, share_id: &str, ) -> Result, String> { let shares = client::unshare_note(url, token, note_id, share_id).await?; mark_shared(db, note_id, &shares)?; Ok(shares) } #[cfg(test)] mod tests { use super::*; use crate::sync::client::Member; fn db() -> Db { crate::local::open_in_memory().expect("in-memory db") } #[test] fn an_unlinked_device_explains_that_sharing_needs_a_server() { assert_eq!(stored_link(&db()).unwrap_err(), NEEDS_SERVER); } #[test] fn the_shared_flag_follows_the_servers_answer_without_dirtying_the_note() { let db = db(); { let conn = db.conn().unwrap(); conn.execute( "INSERT INTO notes (id, body, created_at, updated_at, dirty) VALUES ('n1', 'x', '2026-01-01', '2026-01-01', 0)", [], ) .unwrap(); } let one = NoteShare { id: "s1".into(), member: Some(Member { id: "u2".into(), display_name: "Sam".into(), email: "sam@example.test".into(), }), group: None, permission: "view".into(), created_at: None, }; let read = |db: &Db| -> (bool, i64) { let conn = db.conn().unwrap(); conn.query_row("SELECT shared, dirty FROM notes WHERE id = 'n1'", [], |r| { Ok((r.get(0)?, r.get(1)?)) }) .unwrap() }; mark_shared(&db, "n1", &[one]).unwrap(); assert_eq!(read(&db), (true, 0)); mark_shared(&db, "n1", &[]).unwrap(); assert_eq!(read(&db), (false, 0)); } }