BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: moderation and preferences management

+874 -26
+5 -5
docs/tasks/13-release.md
··· 10 10 11 11 #### Backend 12 12 13 - - [ ] `ModerationService` in Rust - fetch labeler policies, evaluate labels into `ModerationDecision`, cache in `labeler_cache` table 14 - - [ ] Send `atproto-accept-labelers` header with all API requests (built-in Bluesky labeler + user-subscribed labelers) 15 - - [ ] Moderation preferences storage - per-account JSON in `app_settings` keyed by `moderation_preferences::{did}` 16 - - [ ] `create_report` command - calls `com.atproto.moderation.createReport` 17 - - [ ] `get_distribution_channel` command - returns compile-time `DISTRIBUTION_CHANNEL` env var 13 + - [x] `ModerationService` in Rust - fetch labeler policies, evaluate labels into `ModerationDecision`, cache in `labeler_cache` table 14 + - [x] Send `atproto-accept-labelers` header with all API requests (built-in Bluesky labeler + user-subscribed labelers) 15 + - [x] Moderation preferences storage - per-account JSON in `app_settings` keyed by `moderation_preferences::{did}` 16 + - [x] `create_report` command - calls `com.atproto.moderation.createReport` 17 + - [x] `get_distribution_channel` command - returns compile-time `DISTRIBUTION_CHANNEL` env var 18 18 19 19 #### Frontend 20 20
-3
src-tauri/src/columns.rs
··· 54 54 55 55 let insert_position = match position { 56 56 Some(pos) => { 57 - // Shift existing columns at or after this position down by one 58 57 conn.execute( 59 58 "UPDATE columns SET position = position + 1 60 59 WHERE account_did = ?1 AND position >= ?2", ··· 63 62 pos as i64 64 63 } 65 64 None => { 66 - // Append: find the current max position 67 65 let max: Option<i64> = conn 68 66 .query_row( 69 67 "SELECT MAX(position) FROM columns WHERE account_did = ?1", ··· 146 144 147 145 let conn = state.auth_store.lock_connection()?; 148 146 149 - // Verify the column exists first 150 147 let exists: bool = conn 151 148 .query_row("SELECT 1 FROM columns WHERE id = ?1", params![id], |_| Ok(true)) 152 149 .unwrap_or(false);
+1
src-tauri/src/commands/mod.rs
··· 13 13 pub mod drafts; 14 14 pub mod explorer; 15 15 pub mod media; 16 + pub mod moderation; 16 17 pub mod search; 17 18 pub mod settings; 18 19
+113
src-tauri/src/commands/moderation.rs
··· 1 + use super::super::error::Result; 2 + use super::super::moderation::{self, ModerationUI, ReportSubjectInput, StoredModerationPrefs}; 3 + use super::super::state::AppState; 4 + use tauri_plugin_log::log; 5 + 6 + type State<'a> = tauri::State<'a, AppState>; 7 + 8 + /// Return the moderation preferences for the currently active account. 9 + #[tauri::command] 10 + pub fn get_moderation_prefs(state: State<'_>) -> Result<StoredModerationPrefs> { 11 + moderation::get_prefs(&state) 12 + } 13 + 14 + /// Enable or disable adult content for the currently active account. 15 + #[tauri::command] 16 + pub async fn set_adult_content_enabled(enabled: bool, state: State<'_>) -> Result<()> { 17 + moderation::set_adult_content(&state, enabled).await 18 + } 19 + 20 + /// Set the visibility preference for a specific label value from a specific labeler. 21 + /// 22 + /// `visibility` must be one of `"ignore"`, `"warn"`, or `"hide"`. 23 + #[tauri::command] 24 + pub async fn set_label_preference( 25 + labeler_did: String, label: String, visibility: String, state: State<'_>, 26 + ) -> Result<()> { 27 + moderation::set_label_pref(&state, labeler_did, label, visibility).await 28 + } 29 + 30 + /// Subscribe the active account to a labeler, fetch its policies, and update 31 + /// the `atproto-accept-labelers` header on the current session. 32 + #[tauri::command] 33 + pub async fn subscribe_labeler(did: String, state: State<'_>) -> Result<()> { 34 + moderation::subscribe_labeler(&state, did).await 35 + } 36 + 37 + /// Remove a labeler subscription and update the session headers. 38 + #[tauri::command] 39 + pub async fn unsubscribe_labeler(did: String, state: State<'_>) -> Result<()> { 40 + moderation::unsubscribe_labeler(&state, did).await 41 + } 42 + 43 + /// Evaluate a set of labels against the user's moderation preferences. 44 + /// 45 + /// `labels_json` – JSON array of `com.atproto.label.defs#label` objects. 46 + /// 47 + /// Returns a `ModerationUI` describing what the frontend should do with the content. 48 + #[tauri::command] 49 + pub async fn moderate_content(labels_json: String, state: State<'_>) -> Result<ModerationUI> { 50 + let prefs = moderation::get_prefs(&state)?; 51 + let accepted_dids = moderation::accepted_labeler_dids(&prefs); 52 + 53 + let session = { 54 + let did = state 55 + .active_session 56 + .read() 57 + .map_err(|_| super::super::error::AppError::StatePoisoned("active_session"))? 58 + .as_ref() 59 + .ok_or_else(|| super::super::error::AppError::Validation("no active account".into()))? 60 + .did 61 + .clone(); 62 + state 63 + .sessions 64 + .read() 65 + .map_err(|_| super::super::error::AppError::StatePoisoned("sessions"))? 66 + .get(&did) 67 + .cloned() 68 + .ok_or_else(|| super::super::error::AppError::validation(format!("session not found for {did}")))? 69 + }; 70 + 71 + let defs = moderation::build_labeler_defs(&session, state.inner(), &accepted_dids).await; 72 + 73 + moderation::evaluate_labels(&labels_json, &prefs, &defs, &accepted_dids) 74 + } 75 + 76 + /// Submit a content or account report to the Bluesky moderation service. 77 + /// 78 + /// `subject` must be `{"type":"repo","did":"..."}` or `{"type":"record","uri":"...","cid":"..."}`. 79 + /// `reason_type` is a string like `"com.atproto.moderation.defs#reasonSpam"`. 80 + #[tauri::command] 81 + pub async fn create_report( 82 + subject: ReportSubjectInput, reason_type: String, reason: Option<String>, state: State<'_>, 83 + ) -> Result<i64> { 84 + let session = { 85 + let did = state 86 + .active_session 87 + .read() 88 + .map_err(|_| super::super::error::AppError::StatePoisoned("active_session"))? 89 + .as_ref() 90 + .ok_or_else(|| super::super::error::AppError::Validation("no active account".into()))? 91 + .did 92 + .clone(); 93 + state 94 + .sessions 95 + .read() 96 + .map_err(|_| super::super::error::AppError::StatePoisoned("sessions"))? 97 + .get(&did) 98 + .cloned() 99 + .ok_or_else(|| super::super::error::AppError::validation(format!("session not found for {did}")))? 100 + }; 101 + 102 + log::info!("submitting report (reason_type={reason_type})"); 103 + moderation::submit_report(&session, subject, reason_type, reason).await 104 + } 105 + 106 + /// Return the distribution channel this binary was compiled for. 107 + /// 108 + /// Returns `"github"` (default), `"mac_app_store"`, or `"microsoft_store"`. 109 + /// Set the `DISTRIBUTION_CHANNEL` environment variable at compile time to override. 110 + #[tauri::command] 111 + pub fn get_distribution_channel() -> &'static str { 112 + moderation::distribution_channel() 113 + }
+1
src-tauri/src/db.rs
··· 59 59 include_str!("migrations/010_embeddings_opt_in.sql"), 60 60 ), 61 61 Migration::new(11, "drafts", include_str!("migrations/011_drafts.sql")), 62 + Migration::new(12, "labeler_cache", include_str!("migrations/012_labeler_cache.sql")), 62 63 ]; 63 64 64 65 pub fn initialize_database(app: &AppHandle) -> Result<DbPool, AppError> {
+2 -5
src-tauri/src/drafts.rs
··· 425 425 title: None, 426 426 }; 427 427 428 - // Bob submits with alice's draft id — should insert a new draft, not update alice's 429 428 let saved = db_save_draft(&conn, "did:plc:bob", &input).expect("save should succeed"); 430 429 assert_ne!(saved.id, alice_draft.id, "cross-account update must not occur"); 431 430 ··· 462 461 fn list_drafts_ordered_by_updated_at_desc() { 463 462 let conn = draft_db(); 464 463 465 - // Insert with explicit timestamps to ensure ordering 466 464 conn.execute( 467 465 "INSERT INTO drafts (id, account_did, text, created_at, updated_at) 468 466 VALUES ('draft-old', 'did:plc:alice', 'old', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z')", ··· 523 521 #[test] 524 522 fn delete_draft_is_idempotent_for_missing_id() { 525 523 let conn = draft_db(); 526 - // Deleting a non-existent draft should not error 527 524 db_delete_draft(&conn, "ghost-id").expect("delete of missing draft should not error"); 528 525 } 529 526 ··· 649 646 account_did: "did:plc:alice".to_string(), 650 647 text: "broken reply".to_string(), 651 648 reply_parent_uri: Some("at://did:plc:p/app.bsky.feed.post/1".to_string()), 652 - reply_parent_cid: None, // missing 649 + reply_parent_cid: None, 653 650 reply_root_uri: None, 654 651 reply_root_cid: None, 655 652 quote_uri: None, ··· 721 718 reply_root_uri: None, 722 719 reply_root_cid: None, 723 720 quote_uri: Some("at://did:plc:q/app.bsky.feed.post/abc".to_string()), 724 - quote_cid: None, // missing 721 + quote_cid: None, 725 722 title: None, 726 723 created_at: "2024-01-01T00:00:00.000Z".to_string(), 727 724 updated_at: "2024-01-01T00:00:00.000Z".to_string(),
-4
src-tauri/src/feed.rs
··· 225 225 AppError::validation("putPreferences error") 226 226 })?; 227 227 228 - // Bluesky may return a 200 with no body for putPreferences. jacquard's default 229 - // unit decoder still tries to parse JSON, which raises an EOF on successful writes. 230 228 if accepts_empty_put_preferences_response(response.status(), response.buffer()) { 231 229 return Ok(()); 232 230 } ··· 764 762 AppError::validation("Could not save this post.") 765 763 })?; 766 764 767 - // Bluesky may return a 200 with no body for bookmark writes. jacquard's default 768 - // unit decoder still attempts to parse JSON, which raises an EOF on success. 769 765 if accepts_empty_bookmark_response(response.status(), response.buffer()) { 770 766 return Ok(()); 771 767 }
+10 -1
src-tauri/src/lib.rs
··· 11 11 mod explorer; 12 12 mod feed; 13 13 mod media; 14 + mod moderation; 14 15 mod notifications; 15 16 mod search; 16 17 mod settings; ··· 169 170 cmd::drafts::get_draft, 170 171 cmd::drafts::save_draft, 171 172 cmd::drafts::delete_draft, 172 - cmd::drafts::submit_draft 173 + cmd::drafts::submit_draft, 174 + cmd::moderation::get_moderation_prefs, 175 + cmd::moderation::set_adult_content_enabled, 176 + cmd::moderation::set_label_preference, 177 + cmd::moderation::subscribe_labeler, 178 + cmd::moderation::unsubscribe_labeler, 179 + cmd::moderation::moderate_content, 180 + cmd::moderation::create_report, 181 + cmd::moderation::get_distribution_channel 173 182 ]) 174 183 .run(tauri::generate_context!()) 175 184 .expect("error while running tauri application");
+5
src-tauri/src/migrations/012_labeler_cache.sql
··· 1 + CREATE TABLE IF NOT EXISTS labeler_cache ( 2 + labeler_did TEXT PRIMARY KEY, 3 + policies_json TEXT NOT NULL, 4 + fetched_at INTEGER NOT NULL 5 + );
+700
src-tauri/src/moderation.rs
··· 1 + use super::auth::LazuriteOAuthSession; 2 + use super::error::{AppError, Result}; 3 + use super::state::AppState; 4 + use jacquard::api::app_bsky::labeler::get_services::GetServices; 5 + use jacquard::api::app_bsky::labeler::get_services::GetServicesOutputViewsItem; 6 + use jacquard::api::com_atproto::admin::RepoRef; 7 + use jacquard::api::com_atproto::label::{Label, LabelValueDefinition}; 8 + use jacquard::api::com_atproto::moderation::create_report::{CreateReport, CreateReportSubject}; 9 + use jacquard::api::com_atproto::moderation::ReasonType; 10 + use jacquard::api::com_atproto::repo::strong_ref::StrongRef; 11 + use jacquard::moderation::moderate; 12 + use jacquard::moderation::{Blur, LabelPref, LabeledRecord, LabelerDefs, ModerationDecision, ModerationPrefs}; 13 + use jacquard::types::aturi::AtUri; 14 + use jacquard::types::cid::Cid; 15 + use jacquard::types::did::Did; 16 + use jacquard::xrpc::{CallOptions, XrpcClient}; 17 + use jacquard::{CowStr, IntoStatic}; 18 + use rusqlite::{params, Connection, OptionalExtension}; 19 + use serde::{Deserialize, Serialize}; 20 + use std::collections::HashMap; 21 + use std::sync::Arc; 22 + use std::time::{SystemTime, UNIX_EPOCH}; 23 + use tauri_plugin_log::log; 24 + 25 + /// The built-in Bluesky safety labeler DID. Always included in the accept-labelers header. 26 + pub const BUILTIN_LABELER_DID: &str = "did:plc:ar7c4by46qjdydhdevvrndac"; 27 + 28 + /// How long to keep labeler policies in the local cache before re-fetching. 29 + const LABELER_CACHE_TTL_SECS: i64 = 3600; 30 + 31 + /// Maximum number of user-subscribed labelers (Bluesky limit). 32 + pub const MAX_CUSTOM_LABELERS: usize = 20; 33 + 34 + /// User's moderation preferences, persisted as JSON in `app_settings`. 35 + /// 36 + /// Key in the table: `moderation_preferences::{did}` 37 + #[derive(Debug, Clone, Serialize, Deserialize, Default)] 38 + #[serde(rename_all = "camelCase")] 39 + pub struct StoredModerationPrefs { 40 + /// Whether adult-only content may be revealed by the user. 41 + pub adult_content_enabled: bool, 42 + /// DIDs of labelers the user has subscribed to (does not include the built-in labeler). 43 + pub subscribed_labelers: Vec<String>, 44 + /// Per-labeler label-visibility overrides. 45 + /// 46 + /// Map: labeler DID → (label identifier → "ignore" | "warn" | "hide") 47 + pub label_preferences: HashMap<String, HashMap<String, String>>, 48 + } 49 + 50 + /// The UI action the frontend should apply to a piece of content. 51 + #[derive(Debug, Clone, Serialize)] 52 + #[serde(rename_all = "camelCase")] 53 + pub struct ModerationUI { 54 + /// Hide content completely. 55 + pub filter: bool, 56 + /// Blur level: "none" | "content" | "media" 57 + pub blur: String, 58 + /// Show a red alert badge. 59 + pub alert: bool, 60 + /// Show an informational badge. 61 + pub inform: bool, 62 + /// User cannot override the decision (e.g. legal takedown). 63 + pub no_override: bool, 64 + } 65 + 66 + impl From<ModerationDecision> for ModerationUI { 67 + fn from(d: ModerationDecision) -> Self { 68 + Self { 69 + filter: d.filter, 70 + blur: match d.blur { 71 + Blur::None => "none".into(), 72 + Blur::Content => "content".into(), 73 + Blur::Media => "media".into(), 74 + }, 75 + alert: d.alert, 76 + inform: d.inform, 77 + no_override: d.no_override, 78 + } 79 + } 80 + } 81 + 82 + /// Input description of what to report. 83 + #[derive(Debug, Deserialize)] 84 + #[serde(rename_all = "camelCase", tag = "type")] 85 + pub enum ReportSubjectInput { 86 + /// Report a whole account/profile. 87 + Repo { did: String }, 88 + /// Report a specific record (post, etc.). 89 + Record { uri: String, cid: String }, 90 + } 91 + 92 + pub fn prefs_key(did: &str) -> String { 93 + format!("moderation_preferences::{did}") 94 + } 95 + 96 + pub fn load_prefs(conn: &Connection, did: &str) -> Result<StoredModerationPrefs> { 97 + let key = prefs_key(did); 98 + let maybe_json: Option<String> = conn 99 + .query_row("SELECT value FROM app_settings WHERE key = ?1", params![key], |row| { 100 + row.get(0) 101 + }) 102 + .optional()?; 103 + 104 + match maybe_json { 105 + None => Ok(StoredModerationPrefs::default()), 106 + Some(json) => serde_json::from_str(&json).map_err(|error| { 107 + log::warn!("failed to deserialize moderation prefs for {did}: {error}"); 108 + AppError::SerdeJson(error) 109 + }), 110 + } 111 + } 112 + 113 + pub fn save_prefs(conn: &Connection, did: &str, prefs: &StoredModerationPrefs) -> Result<()> { 114 + let key = prefs_key(did); 115 + let json = serde_json::to_string(prefs)?; 116 + conn.execute( 117 + "INSERT INTO app_settings(key, value) VALUES(?1, ?2) 118 + ON CONFLICT(key) DO UPDATE SET value = excluded.value", 119 + params![key, json], 120 + )?; 121 + Ok(()) 122 + } 123 + 124 + /// Load cached labeler policies. Returns `None` when absent or stale. 125 + pub fn load_labeler_cache(conn: &Connection, labeler_did: &str) -> Result<Option<Vec<LabelValueDefinition<'static>>>> { 126 + let now = unix_now(); 127 + let row: Option<(String, i64)> = conn 128 + .query_row( 129 + "SELECT policies_json, fetched_at FROM labeler_cache WHERE labeler_did = ?1", 130 + params![labeler_did], 131 + |row| Ok((row.get(0)?, row.get(1)?)), 132 + ) 133 + .optional()?; 134 + 135 + let Some((json, fetched_at)) = row else { 136 + return Ok(None); 137 + }; 138 + 139 + if now - fetched_at > LABELER_CACHE_TTL_SECS { 140 + log::debug!("labeler cache expired for {labeler_did}"); 141 + return Ok(None); 142 + } 143 + 144 + let defs = serde_json::from_str::<Vec<LabelValueDefinition<'_>>>(&json).map_err(|error| { 145 + log::warn!("failed to deserialize labeler cache for {labeler_did}: {error}"); 146 + AppError::SerdeJson(error) 147 + })?; 148 + let defs = defs 149 + .into_iter() 150 + .map(IntoStatic::into_static) 151 + .collect::<Vec<LabelValueDefinition<'static>>>(); 152 + 153 + Ok(Some(defs)) 154 + } 155 + 156 + pub fn store_labeler_cache(conn: &Connection, labeler_did: &str, defs: &[LabelValueDefinition<'_>]) -> Result<()> { 157 + let json = serde_json::to_string(defs)?; 158 + let now = unix_now(); 159 + conn.execute( 160 + "INSERT INTO labeler_cache(labeler_did, policies_json, fetched_at) VALUES(?1, ?2, ?3) 161 + ON CONFLICT(labeler_did) DO UPDATE SET policies_json = excluded.policies_json, fetched_at = excluded.fetched_at", 162 + params![labeler_did, json, now], 163 + )?; 164 + Ok(()) 165 + } 166 + 167 + async fn get_session(state: &AppState) -> Result<Arc<LazuriteOAuthSession>> { 168 + let did = state 169 + .active_session 170 + .read() 171 + .map_err(|error| { 172 + log::error!("active_session poisoned: {error}"); 173 + AppError::StatePoisoned("active_session") 174 + })? 175 + .as_ref() 176 + .ok_or_else(|| AppError::Validation("no active account".into()))? 177 + .did 178 + .clone(); 179 + 180 + state 181 + .sessions 182 + .read() 183 + .map_err(|error| AppError::state_poisoned(format!("sessions poisoned: {error}")))? 184 + .get(&did) 185 + .cloned() 186 + .ok_or_else(|| AppError::validation(format!("session not found for active account {did}"))) 187 + } 188 + 189 + fn active_did(state: &AppState) -> Result<String> { 190 + state 191 + .active_session 192 + .read() 193 + .map_err(|error| AppError::state_poisoned(format!("active_session poisoned: {error}")))? 194 + .as_ref() 195 + .map(|s| s.did.clone()) 196 + .ok_or_else(|| AppError::Validation("no active account".into())) 197 + } 198 + 199 + /// Build the complete list of accepted labeler DIDs (built-in + user subscriptions). 200 + pub fn accepted_labeler_dids(prefs: &StoredModerationPrefs) -> Vec<String> { 201 + let mut dids = vec![BUILTIN_LABELER_DID.to_string()]; 202 + for did in &prefs.subscribed_labelers { 203 + if !dids.contains(did) { 204 + dids.push(did.clone()); 205 + } 206 + } 207 + dids 208 + } 209 + 210 + /// Apply the user's current labeler subscriptions as session-level `atproto-accept-labelers` headers. 211 + /// 212 + /// This must be called after changing labeler subscriptions so that all subsequent API calls 213 + /// carry the correct header. 214 + pub async fn apply_labeler_headers(session: &LazuriteOAuthSession, prefs: &StoredModerationPrefs) { 215 + let dids: Vec<CowStr<'static>> = accepted_labeler_dids(prefs) 216 + .into_iter() 217 + .map(|did| CowStr::from(did)) 218 + .collect(); 219 + let opts = CallOptions { atproto_accept_labelers: Some(dids), ..Default::default() }; 220 + session.set_options(opts).await; 221 + log::debug!( 222 + "updated atproto-accept-labelers to {} labeler(s)", 223 + prefs.subscribed_labelers.len() + 1 224 + ); 225 + } 226 + 227 + /// Fetch labeler policies from the Bluesky AppView for the given DIDs. 228 + /// 229 + /// Returns a list of `(Did<'static>, Vec<LabelValueDefinition<'static>>)` pairs. 230 + /// Skips DIDs where the fetch fails (logged as warnings) so callers get partial results. 231 + /// 232 + /// This function does **not** access the database — callers are responsible for caching. 233 + pub async fn fetch_labeler_policies_from_api( 234 + session: &LazuriteOAuthSession, dids: &[String], 235 + ) -> Vec<(Did<'static>, Vec<LabelValueDefinition<'static>>)> { 236 + if dids.is_empty() { 237 + return Vec::new(); 238 + } 239 + 240 + let parsed_dids: Vec<Did<'_>> = dids 241 + .iter() 242 + .filter_map(|s| { 243 + Did::new(s) 244 + .map_err(|error| { 245 + log::warn!("skipping invalid labeler DID '{s}': {error}"); 246 + error 247 + }) 248 + .ok() 249 + }) 250 + .collect(); 251 + 252 + if parsed_dids.is_empty() { 253 + return Vec::new(); 254 + } 255 + 256 + log::info!("fetching policies for {} labeler(s) from API", parsed_dids.len()); 257 + 258 + let request = GetServices::new().dids(parsed_dids).detailed(true).build(); 259 + let response = match session.send(request).await { 260 + Ok(r) => r, 261 + Err(error) => { 262 + log::error!("failed to fetch labeler services: {error}"); 263 + return Vec::new(); 264 + } 265 + }; 266 + 267 + let output = match response.into_output() { 268 + Ok(o) => o, 269 + Err(error) => { 270 + log::error!("failed to decode labeler services response: {error}"); 271 + return Vec::new(); 272 + } 273 + }; 274 + 275 + output 276 + .views 277 + .into_iter() 278 + .filter_map(|view| { 279 + let GetServicesOutputViewsItem::LabelerViewDetailed(detailed) = view else { 280 + return None; 281 + }; 282 + let did = detailed.creator.did.clone().into_static(); 283 + let label_defs = detailed 284 + .policies 285 + .label_value_definitions 286 + .unwrap_or_default() 287 + .into_iter() 288 + .map(IntoStatic::into_static) 289 + .collect::<Vec<_>>(); 290 + Some((did, label_defs)) 291 + }) 292 + .collect() 293 + } 294 + 295 + /// Build `LabelerDefs` for the given DIDs, using the local cache where available 296 + /// and fetching from the API for any missing/stale entries. 297 + /// 298 + /// The database connection is never held across an `await` point. 299 + pub async fn build_labeler_defs( 300 + session: &LazuriteOAuthSession, state: &AppState, dids: &[String], 301 + ) -> LabelerDefs<'static> { 302 + let (mut defs, missing) = { 303 + let Ok(conn) = state.auth_store.lock_connection() else { 304 + log::error!("failed to lock DB for labeler cache read"); 305 + return LabelerDefs::new(); 306 + }; 307 + 308 + let mut defs = LabelerDefs::new(); 309 + let mut missing: Vec<String> = Vec::new(); 310 + 311 + for did_str in dids { 312 + match load_labeler_cache(&conn, did_str) { 313 + Ok(Some(cached_defs)) => { 314 + if let Ok(did) = Did::new(did_str) { 315 + defs.insert(did.into_static(), cached_defs); 316 + } 317 + } 318 + Ok(None) => missing.push(did_str.clone()), 319 + Err(error) => { 320 + log::warn!("failed to read labeler cache for {did_str}: {error}"); 321 + missing.push(did_str.clone()); 322 + } 323 + } 324 + } 325 + 326 + (defs, missing) 327 + }; 328 + 329 + if !missing.is_empty() { 330 + let fetched = fetch_labeler_policies_from_api(session, &missing).await; 331 + 332 + { 333 + match state.auth_store.lock_connection() { 334 + Ok(conn) => { 335 + for (did, label_defs) in &fetched { 336 + if let Err(error) = store_labeler_cache(&conn, did.as_str(), label_defs) { 337 + log::warn!("failed to cache labeler policies for {}: {error}", did.as_str()); 338 + } 339 + } 340 + } 341 + Err(error) => { 342 + log::warn!("failed to lock DB for labeler cache write: {error}"); 343 + } 344 + } 345 + } 346 + 347 + for (did, label_defs) in fetched { 348 + defs.insert(did, label_defs); 349 + } 350 + } 351 + 352 + defs 353 + } 354 + 355 + /// Submit a moderation report to the Bluesky moderation service. 356 + pub async fn submit_report( 357 + session: &LazuriteOAuthSession, subject: ReportSubjectInput, reason_type_str: String, reason: Option<String>, 358 + ) -> Result<i64> { 359 + let reason_type = ReasonType::from(reason_type_str); 360 + let subject = match subject { 361 + ReportSubjectInput::Repo { did } => { 362 + let parsed_did = Did::new(&did) 363 + .map_err(|_| AppError::validation("invalid DID in report subject"))? 364 + .into_static(); 365 + let repo_ref = RepoRef::new().did(parsed_did).build(); 366 + CreateReportSubject::RepoRef(Box::new(repo_ref)) 367 + } 368 + ReportSubjectInput::Record { uri, cid } => { 369 + let parsed_uri = AtUri::new(&uri) 370 + .map_err(|_| AppError::validation("invalid AT-URI in report subject"))? 371 + .into_static(); 372 + let parsed_cid = Cid::str(&cid).into_static(); 373 + parsed_cid 374 + .to_ipld() 375 + .map_err(|error| AppError::validation(format!("invalid CID in report subject: {error}")))?; 376 + let strong_ref = StrongRef::new().uri(parsed_uri).cid(parsed_cid).build(); 377 + CreateReportSubject::StrongRef(Box::new(strong_ref)) 378 + } 379 + }; 380 + 381 + let mut builder = CreateReport::new().reason_type(reason_type).subject(subject); 382 + if let Some(reason_text) = reason { 383 + builder = builder.reason(CowStr::from(reason_text)); 384 + } 385 + 386 + let request = builder.build(); 387 + let response = session.send(request).await.map_err(|error| { 388 + log::error!("create_report API error: {error}"); 389 + AppError::validation("failed to submit report") 390 + })?; 391 + 392 + let output = response.into_output().map_err(|error| { 393 + log::error!("create_report response decode error: {error}"); 394 + AppError::validation("unexpected response from moderation service") 395 + })?; 396 + 397 + log::info!("report submitted: id={}", output.id); 398 + Ok(output.id) 399 + } 400 + 401 + /// Convert stored prefs to the jacquard `ModerationPrefs` type. 402 + fn to_jacquard_prefs(prefs: &StoredModerationPrefs) -> ModerationPrefs<'static> { 403 + let labelers = prefs 404 + .label_preferences 405 + .iter() 406 + .filter_map(|(did_str, label_map)| { 407 + let did = Did::new(did_str).ok()?.into_static(); 408 + let pref_map = label_map 409 + .iter() 410 + .map(|(label, vis)| { 411 + let pref = parse_label_pref(vis); 412 + (CowStr::from(label.clone()), pref) 413 + }) 414 + .collect(); 415 + Some((did, pref_map)) 416 + }) 417 + .collect(); 418 + 419 + ModerationPrefs { adult_content_enabled: prefs.adult_content_enabled, labels: HashMap::new(), labelers } 420 + } 421 + 422 + fn parse_label_pref(s: &str) -> LabelPref { 423 + match s { 424 + "hide" => LabelPref::Hide, 425 + "warn" => LabelPref::Warn, 426 + _ => LabelPref::Ignore, 427 + } 428 + } 429 + 430 + /// Evaluate a JSON array of ATProto labels against the user's moderation preferences. 431 + /// 432 + /// `labels_json` – JSON array of `com.atproto.label.defs#label` objects. 433 + /// `accepted_dids` – DIDs of labelers whose labels should be evaluated (built-in + subscribed). 434 + pub fn evaluate_labels( 435 + labels_json: &str, prefs: &StoredModerationPrefs, defs: &LabelerDefs<'_>, accepted_dids: &[String], 436 + ) -> Result<ModerationUI> { 437 + let labels = serde_json::from_str::<Vec<Label<'_>>>(labels_json).map_err(|error| { 438 + log::warn!("failed to deserialize labels: {error}"); 439 + AppError::validation("invalid labels format") 440 + })?; 441 + let labels = labels 442 + .into_iter() 443 + .map(IntoStatic::into_static) 444 + .collect::<Vec<Label<'static>>>(); 445 + 446 + let jacquard_prefs = to_jacquard_prefs(prefs); 447 + 448 + let accepted_labelers: Vec<Did<'_>> = accepted_dids.iter().filter_map(|s| Did::new(s).ok()).collect(); 449 + 450 + let record = LabeledRecord { record: (), labels }; 451 + let decision = moderate(&record, &jacquard_prefs, defs, &accepted_labelers); 452 + Ok(ModerationUI::from(decision)) 453 + } 454 + 455 + /// Load moderation preferences for the currently active account. 456 + pub fn get_prefs(state: &AppState) -> Result<StoredModerationPrefs> { 457 + let did = active_did(state)?; 458 + let conn = state.auth_store.lock_connection()?; 459 + load_prefs(&conn, &did) 460 + } 461 + 462 + /// Toggle adult-content access for the active account and persist. 463 + pub async fn set_adult_content(state: &AppState, enabled: bool) -> Result<()> { 464 + let did = active_did(state)?; 465 + let mut prefs = { 466 + let conn = state.auth_store.lock_connection()?; 467 + load_prefs(&conn, &did)? 468 + }; 469 + prefs.adult_content_enabled = enabled; 470 + let conn = state.auth_store.lock_connection()?; 471 + save_prefs(&conn, &did, &prefs) 472 + } 473 + 474 + /// Set the visibility preference for a specific label from a specific labeler. 475 + pub async fn set_label_pref(state: &AppState, labeler_did: String, label: String, visibility: String) -> Result<()> { 476 + if !matches!(visibility.as_str(), "ignore" | "warn" | "hide") { 477 + return Err(AppError::validation("visibility must be 'ignore', 'warn', or 'hide'")); 478 + } 479 + 480 + let did = active_did(state)?; 481 + let mut prefs = { 482 + let conn = state.auth_store.lock_connection()?; 483 + load_prefs(&conn, &did)? 484 + }; 485 + 486 + prefs 487 + .label_preferences 488 + .entry(labeler_did) 489 + .or_default() 490 + .insert(label, visibility); 491 + 492 + let conn = state.auth_store.lock_connection()?; 493 + save_prefs(&conn, &did, &prefs) 494 + } 495 + 496 + /// Subscribe the active account to a labeler and update the session headers. 497 + pub async fn subscribe_labeler(state: &AppState, labeler_did: String) -> Result<()> { 498 + Did::new(&labeler_did).map_err(|_| AppError::validation("invalid labeler DID"))?; 499 + 500 + let did = active_did(state)?; 501 + let mut prefs = { 502 + let conn = state.auth_store.lock_connection()?; 503 + load_prefs(&conn, &did)? 504 + }; 505 + 506 + if prefs.subscribed_labelers.contains(&labeler_did) { 507 + return Ok(()); 508 + } 509 + 510 + if prefs.subscribed_labelers.len() >= MAX_CUSTOM_LABELERS { 511 + return Err(AppError::validation(format!( 512 + "you can subscribe to at most {MAX_CUSTOM_LABELERS} custom labelers" 513 + ))); 514 + } 515 + 516 + prefs.subscribed_labelers.push(labeler_did.clone()); 517 + { 518 + let conn = state.auth_store.lock_connection()?; 519 + save_prefs(&conn, &did, &prefs)?; 520 + } 521 + 522 + let session = get_session(state).await?; 523 + apply_labeler_headers(&session, &prefs).await; 524 + 525 + let fetched = fetch_labeler_policies_from_api(&session, &[labeler_did]).await; 526 + { 527 + match state.auth_store.lock_connection() { 528 + Ok(conn) => { 529 + for (did, label_defs) in &fetched { 530 + if let Err(error) = store_labeler_cache(&conn, did.as_str(), label_defs) { 531 + log::warn!("failed to cache labeler policy after subscribe: {error}"); 532 + } 533 + } 534 + } 535 + Err(error) => { 536 + log::warn!("failed to lock DB for post-subscribe cache write: {error}"); 537 + } 538 + } 539 + } 540 + 541 + Ok(()) 542 + } 543 + 544 + /// Unsubscribe the active account from a labeler and update the session headers. 545 + pub async fn unsubscribe_labeler(state: &AppState, labeler_did: String) -> Result<()> { 546 + let did = active_did(state)?; 547 + let mut prefs = { 548 + let conn = state.auth_store.lock_connection()?; 549 + load_prefs(&conn, &did)? 550 + }; 551 + 552 + let before = prefs.subscribed_labelers.len(); 553 + prefs.subscribed_labelers.retain(|d| d != &labeler_did); 554 + 555 + if prefs.subscribed_labelers.len() == before { 556 + return Ok(()); 557 + } 558 + 559 + prefs.label_preferences.remove(&labeler_did); 560 + { 561 + let conn = state.auth_store.lock_connection()?; 562 + save_prefs(&conn, &did, &prefs)?; 563 + } 564 + 565 + let session = get_session(state).await?; 566 + apply_labeler_headers(&session, &prefs).await; 567 + 568 + Ok(()) 569 + } 570 + 571 + fn unix_now() -> i64 { 572 + SystemTime::now() 573 + .duration_since(UNIX_EPOCH) 574 + .map(|d| d.as_secs() as i64) 575 + .unwrap_or(0) 576 + } 577 + 578 + /// Returns the distribution channel this binary was compiled for. 579 + /// 580 + /// Controlled by the `DISTRIBUTION_CHANNEL` environment variable at compile time. 581 + /// Falls back to `"github"` if the variable was not set. 582 + pub fn distribution_channel() -> &'static str { 583 + option_env!("DISTRIBUTION_CHANNEL").unwrap_or("github") 584 + } 585 + 586 + #[cfg(test)] 587 + mod tests { 588 + use super::*; 589 + use rusqlite::Connection; 590 + 591 + fn in_memory_db() -> Connection { 592 + let conn = Connection::open_in_memory().expect("open in-memory db"); 593 + conn.execute_batch( 594 + "CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); 595 + CREATE TABLE labeler_cache ( 596 + labeler_did TEXT PRIMARY KEY, 597 + policies_json TEXT NOT NULL, 598 + fetched_at INTEGER NOT NULL 599 + );", 600 + ) 601 + .expect("create tables"); 602 + conn 603 + } 604 + 605 + #[test] 606 + fn moderation_prefs_round_trip() { 607 + let conn = in_memory_db(); 608 + let did = "did:plc:abc123"; 609 + 610 + let prefs = load_prefs(&conn, did).expect("load default prefs"); 611 + assert!(!prefs.adult_content_enabled); 612 + assert!(prefs.subscribed_labelers.is_empty()); 613 + 614 + let mut updated = prefs; 615 + updated.adult_content_enabled = true; 616 + updated.subscribed_labelers.push("did:plc:labeler1".into()); 617 + updated 618 + .label_preferences 619 + .entry("did:plc:labeler1".into()) 620 + .or_default() 621 + .insert("porn".into(), "hide".into()); 622 + 623 + save_prefs(&conn, did, &updated).expect("save prefs"); 624 + 625 + let loaded = load_prefs(&conn, did).expect("load saved prefs"); 626 + assert!(loaded.adult_content_enabled); 627 + assert_eq!(loaded.subscribed_labelers, vec!["did:plc:labeler1"]); 628 + assert_eq!(loaded.label_preferences["did:plc:labeler1"]["porn"], "hide"); 629 + } 630 + 631 + #[test] 632 + fn labeler_cache_round_trip() { 633 + let conn = in_memory_db(); 634 + let did = "did:plc:labeler1"; 635 + 636 + assert!(load_labeler_cache(&conn, did).expect("load empty cache").is_none()); 637 + 638 + store_labeler_cache(&conn, did, &[]).expect("store empty defs"); 639 + let cached = load_labeler_cache(&conn, did).expect("load cached defs"); 640 + assert!(cached.is_some()); 641 + } 642 + 643 + #[test] 644 + fn labeler_cache_staleness() { 645 + let conn = in_memory_db(); 646 + let did = "did:plc:labeler_stale"; 647 + let old_ts = unix_now() - LABELER_CACHE_TTL_SECS - 1; 648 + 649 + conn.execute( 650 + "INSERT INTO labeler_cache(labeler_did, policies_json, fetched_at) VALUES(?1, '[]', ?2)", 651 + params![did, old_ts], 652 + ) 653 + .expect("insert stale cache"); 654 + 655 + assert!( 656 + load_labeler_cache(&conn, did).expect("load stale").is_none(), 657 + "stale cache entry should be treated as missing" 658 + ); 659 + } 660 + 661 + #[test] 662 + fn accepted_labeler_dids_includes_builtin() { 663 + let prefs = StoredModerationPrefs { subscribed_labelers: vec!["did:plc:custom".into()], ..Default::default() }; 664 + let dids = accepted_labeler_dids(&prefs); 665 + assert!(dids.contains(&BUILTIN_LABELER_DID.to_string())); 666 + assert!(dids.contains(&"did:plc:custom".to_string())); 667 + } 668 + 669 + #[test] 670 + fn accepted_labeler_dids_no_duplicates() { 671 + let prefs = 672 + StoredModerationPrefs { subscribed_labelers: vec![BUILTIN_LABELER_DID.into()], ..Default::default() }; 673 + let dids = accepted_labeler_dids(&prefs); 674 + let count = dids.iter().filter(|d| d.as_str() == BUILTIN_LABELER_DID).count(); 675 + assert_eq!(count, 1, "builtin labeler should not be duplicated"); 676 + } 677 + 678 + #[test] 679 + fn distribution_channel_defaults_to_github() { 680 + let channel = distribution_channel(); 681 + assert!(!channel.is_empty()); 682 + } 683 + 684 + #[test] 685 + fn evaluate_labels_empty_returns_no_moderation() { 686 + let prefs = StoredModerationPrefs::default(); 687 + let defs = LabelerDefs::new(); 688 + let accepted: Vec<String> = vec![]; 689 + let ui = evaluate_labels("[]", &prefs, &defs, &accepted).expect("evaluate"); 690 + assert!(!ui.filter); 691 + assert_eq!(ui.blur, "none"); 692 + assert!(!ui.alert); 693 + assert!(!ui.inform); 694 + } 695 + 696 + #[test] 697 + fn prefs_key_format() { 698 + assert_eq!(prefs_key("did:plc:abc"), "moderation_preferences::did:plc:abc"); 699 + } 700 + }
+37 -8
src-tauri/src/state.rs
··· 5 5 use super::auth::{LazuriteOAuthClient, LazuriteOAuthSession, PersistentAuthStore, StoredAccount}; 6 6 use super::db::DbPool; 7 7 use super::error::AppError; 8 + use super::moderation::{self, StoredModerationPrefs}; 8 9 use jacquard::oauth::authstore::ClientAuthStore; 9 10 use jacquard::oauth::error::OAuthError; 10 11 use jacquard::types::did::Did; ··· 130 131 let (did, session_id) = session.session_info().await; 131 132 let did = did.to_string(); 132 133 let session_id = session_id.to_string(); 134 + self.apply_moderation_headers_for_did(&did, session.as_ref()).await; 133 135 let account_summary_result = async { 134 136 let account_summary = fetch_account_summary(&session, true).await?; 135 137 self.auth_store.upsert_account(&account_summary, &session_id, true)?; ··· 209 211 .clone()) 210 212 } 211 213 214 + async fn apply_moderation_headers_for_did(&self, did: &str, session: &LazuriteOAuthSession) { 215 + let prefs = match self.auth_store.lock_connection() { 216 + Ok(conn) => match moderation::load_prefs(&conn, did) { 217 + Ok(prefs) => prefs, 218 + Err(error) => { 219 + log::warn!("failed to load moderation prefs for {did}: {error}"); 220 + StoredModerationPrefs::default() 221 + } 222 + }, 223 + Err(error) => { 224 + log::warn!("failed to lock DB while loading moderation prefs for {did}: {error}"); 225 + StoredModerationPrefs::default() 226 + } 227 + }; 228 + 229 + moderation::apply_labeler_headers(session, &prefs).await; 230 + } 231 + 212 232 async fn ensure_session( 213 233 &self, account: &StoredAccount, refresh: bool, 214 234 ) -> Result<Arc<LazuriteOAuthSession>, AppError> { 215 - if let Some(existing) = self 216 - .sessions 217 - .read() 218 - .map_err(|_| AppError::StatePoisoned("sessions"))? 219 - .get(&account.did) 220 - .cloned() 221 - { 235 + let existing = { 236 + self.sessions 237 + .read() 238 + .map_err(|_| AppError::StatePoisoned("sessions"))? 239 + .get(&account.did) 240 + .cloned() 241 + }; 242 + 243 + if let Some(existing) = existing { 244 + self.apply_moderation_headers_for_did(&account.did, existing.as_ref()) 245 + .await; 222 246 log::debug!("using cached session for {}", account.handle); 223 247 return Ok(existing); 224 248 } ··· 243 267 log::debug!("restoring session from persisted data for {}", account.handle); 244 268 self.restore_persisted_session(account, &did, &session_id).await? 245 269 }; 270 + self.apply_moderation_headers_for_did(&account.did, session.as_ref()) 271 + .await; 246 272 247 273 self.sessions 248 274 .write() ··· 387 413 388 414 match self.oauth_client.restore(&did, &session_id).await { 389 415 Ok(session) => { 416 + let session = Arc::new(session); 417 + self.apply_moderation_headers_for_did(&account.did, session.as_ref()) 418 + .await; 390 419 self.sessions 391 420 .write() 392 421 .map_err(|_| AppError::StatePoisoned("sessions"))? 393 - .insert(account.did.clone(), Arc::new(session)); 422 + .insert(account.did.clone(), session); 394 423 395 424 self.auth_store.set_active_account(&account.did)?; 396 425 self.refresh_account_cache()?;