···10101111#### Backend
12121313-- [ ] `ModerationService` in Rust - fetch labeler policies, evaluate labels into `ModerationDecision`, cache in `labeler_cache` table
1414-- [ ] Send `atproto-accept-labelers` header with all API requests (built-in Bluesky labeler + user-subscribed labelers)
1515-- [ ] Moderation preferences storage - per-account JSON in `app_settings` keyed by `moderation_preferences::{did}`
1616-- [ ] `create_report` command - calls `com.atproto.moderation.createReport`
1717-- [ ] `get_distribution_channel` command - returns compile-time `DISTRIBUTION_CHANNEL` env var
1313+- [x] `ModerationService` in Rust - fetch labeler policies, evaluate labels into `ModerationDecision`, cache in `labeler_cache` table
1414+- [x] Send `atproto-accept-labelers` header with all API requests (built-in Bluesky labeler + user-subscribed labelers)
1515+- [x] Moderation preferences storage - per-account JSON in `app_settings` keyed by `moderation_preferences::{did}`
1616+- [x] `create_report` command - calls `com.atproto.moderation.createReport`
1717+- [x] `get_distribution_channel` command - returns compile-time `DISTRIBUTION_CHANNEL` env var
18181919#### Frontend
2020
-3
src-tauri/src/columns.rs
···54545555 let insert_position = match position {
5656 Some(pos) => {
5757- // Shift existing columns at or after this position down by one
5857 conn.execute(
5958 "UPDATE columns SET position = position + 1
6059 WHERE account_did = ?1 AND position >= ?2",
···6362 pos as i64
6463 }
6564 None => {
6666- // Append: find the current max position
6765 let max: Option<i64> = conn
6866 .query_row(
6967 "SELECT MAX(position) FROM columns WHERE account_did = ?1",
···146144147145 let conn = state.auth_store.lock_connection()?;
148146149149- // Verify the column exists first
150147 let exists: bool = conn
151148 .query_row("SELECT 1 FROM columns WHERE id = ?1", params![id], |_| Ok(true))
152149 .unwrap_or(false);
+1
src-tauri/src/commands/mod.rs
···1313pub mod drafts;
1414pub mod explorer;
1515pub mod media;
1616+pub mod moderation;
1617pub mod search;
1718pub mod settings;
1819
+113
src-tauri/src/commands/moderation.rs
···11+use super::super::error::Result;
22+use super::super::moderation::{self, ModerationUI, ReportSubjectInput, StoredModerationPrefs};
33+use super::super::state::AppState;
44+use tauri_plugin_log::log;
55+66+type State<'a> = tauri::State<'a, AppState>;
77+88+/// Return the moderation preferences for the currently active account.
99+#[tauri::command]
1010+pub fn get_moderation_prefs(state: State<'_>) -> Result<StoredModerationPrefs> {
1111+ moderation::get_prefs(&state)
1212+}
1313+1414+/// Enable or disable adult content for the currently active account.
1515+#[tauri::command]
1616+pub async fn set_adult_content_enabled(enabled: bool, state: State<'_>) -> Result<()> {
1717+ moderation::set_adult_content(&state, enabled).await
1818+}
1919+2020+/// Set the visibility preference for a specific label value from a specific labeler.
2121+///
2222+/// `visibility` must be one of `"ignore"`, `"warn"`, or `"hide"`.
2323+#[tauri::command]
2424+pub async fn set_label_preference(
2525+ labeler_did: String, label: String, visibility: String, state: State<'_>,
2626+) -> Result<()> {
2727+ moderation::set_label_pref(&state, labeler_did, label, visibility).await
2828+}
2929+3030+/// Subscribe the active account to a labeler, fetch its policies, and update
3131+/// the `atproto-accept-labelers` header on the current session.
3232+#[tauri::command]
3333+pub async fn subscribe_labeler(did: String, state: State<'_>) -> Result<()> {
3434+ moderation::subscribe_labeler(&state, did).await
3535+}
3636+3737+/// Remove a labeler subscription and update the session headers.
3838+#[tauri::command]
3939+pub async fn unsubscribe_labeler(did: String, state: State<'_>) -> Result<()> {
4040+ moderation::unsubscribe_labeler(&state, did).await
4141+}
4242+4343+/// Evaluate a set of labels against the user's moderation preferences.
4444+///
4545+/// `labels_json` – JSON array of `com.atproto.label.defs#label` objects.
4646+///
4747+/// Returns a `ModerationUI` describing what the frontend should do with the content.
4848+#[tauri::command]
4949+pub async fn moderate_content(labels_json: String, state: State<'_>) -> Result<ModerationUI> {
5050+ let prefs = moderation::get_prefs(&state)?;
5151+ let accepted_dids = moderation::accepted_labeler_dids(&prefs);
5252+5353+ let session = {
5454+ let did = state
5555+ .active_session
5656+ .read()
5757+ .map_err(|_| super::super::error::AppError::StatePoisoned("active_session"))?
5858+ .as_ref()
5959+ .ok_or_else(|| super::super::error::AppError::Validation("no active account".into()))?
6060+ .did
6161+ .clone();
6262+ state
6363+ .sessions
6464+ .read()
6565+ .map_err(|_| super::super::error::AppError::StatePoisoned("sessions"))?
6666+ .get(&did)
6767+ .cloned()
6868+ .ok_or_else(|| super::super::error::AppError::validation(format!("session not found for {did}")))?
6969+ };
7070+7171+ let defs = moderation::build_labeler_defs(&session, state.inner(), &accepted_dids).await;
7272+7373+ moderation::evaluate_labels(&labels_json, &prefs, &defs, &accepted_dids)
7474+}
7575+7676+/// Submit a content or account report to the Bluesky moderation service.
7777+///
7878+/// `subject` must be `{"type":"repo","did":"..."}` or `{"type":"record","uri":"...","cid":"..."}`.
7979+/// `reason_type` is a string like `"com.atproto.moderation.defs#reasonSpam"`.
8080+#[tauri::command]
8181+pub async fn create_report(
8282+ subject: ReportSubjectInput, reason_type: String, reason: Option<String>, state: State<'_>,
8383+) -> Result<i64> {
8484+ let session = {
8585+ let did = state
8686+ .active_session
8787+ .read()
8888+ .map_err(|_| super::super::error::AppError::StatePoisoned("active_session"))?
8989+ .as_ref()
9090+ .ok_or_else(|| super::super::error::AppError::Validation("no active account".into()))?
9191+ .did
9292+ .clone();
9393+ state
9494+ .sessions
9595+ .read()
9696+ .map_err(|_| super::super::error::AppError::StatePoisoned("sessions"))?
9797+ .get(&did)
9898+ .cloned()
9999+ .ok_or_else(|| super::super::error::AppError::validation(format!("session not found for {did}")))?
100100+ };
101101+102102+ log::info!("submitting report (reason_type={reason_type})");
103103+ moderation::submit_report(&session, subject, reason_type, reason).await
104104+}
105105+106106+/// Return the distribution channel this binary was compiled for.
107107+///
108108+/// Returns `"github"` (default), `"mac_app_store"`, or `"microsoft_store"`.
109109+/// Set the `DISTRIBUTION_CHANNEL` environment variable at compile time to override.
110110+#[tauri::command]
111111+pub fn get_distribution_channel() -> &'static str {
112112+ moderation::distribution_channel()
113113+}
···425425 title: None,
426426 };
427427428428- // Bob submits with alice's draft id — should insert a new draft, not update alice's
429428 let saved = db_save_draft(&conn, "did:plc:bob", &input).expect("save should succeed");
430429 assert_ne!(saved.id, alice_draft.id, "cross-account update must not occur");
431430···462461 fn list_drafts_ordered_by_updated_at_desc() {
463462 let conn = draft_db();
464463465465- // Insert with explicit timestamps to ensure ordering
466464 conn.execute(
467465 "INSERT INTO drafts (id, account_did, text, created_at, updated_at)
468466 VALUES ('draft-old', 'did:plc:alice', 'old', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z')",
···523521 #[test]
524522 fn delete_draft_is_idempotent_for_missing_id() {
525523 let conn = draft_db();
526526- // Deleting a non-existent draft should not error
527524 db_delete_draft(&conn, "ghost-id").expect("delete of missing draft should not error");
528525 }
529526···649646 account_did: "did:plc:alice".to_string(),
650647 text: "broken reply".to_string(),
651648 reply_parent_uri: Some("at://did:plc:p/app.bsky.feed.post/1".to_string()),
652652- reply_parent_cid: None, // missing
649649+ reply_parent_cid: None,
653650 reply_root_uri: None,
654651 reply_root_cid: None,
655652 quote_uri: None,
···721718 reply_root_uri: None,
722719 reply_root_cid: None,
723720 quote_uri: Some("at://did:plc:q/app.bsky.feed.post/abc".to_string()),
724724- quote_cid: None, // missing
721721+ quote_cid: None,
725722 title: None,
726723 created_at: "2024-01-01T00:00:00.000Z".to_string(),
727724 updated_at: "2024-01-01T00:00:00.000Z".to_string(),
-4
src-tauri/src/feed.rs
···225225 AppError::validation("putPreferences error")
226226 })?;
227227228228- // Bluesky may return a 200 with no body for putPreferences. jacquard's default
229229- // unit decoder still tries to parse JSON, which raises an EOF on successful writes.
230228 if accepts_empty_put_preferences_response(response.status(), response.buffer()) {
231229 return Ok(());
232230 }
···764762 AppError::validation("Could not save this post.")
765763 })?;
766764767767- // Bluesky may return a 200 with no body for bookmark writes. jacquard's default
768768- // unit decoder still attempts to parse JSON, which raises an EOF on success.
769765 if accepts_empty_bookmark_response(response.status(), response.buffer()) {
770766 return Ok(());
771767 }
···11+CREATE TABLE IF NOT EXISTS labeler_cache (
22+ labeler_did TEXT PRIMARY KEY,
33+ policies_json TEXT NOT NULL,
44+ fetched_at INTEGER NOT NULL
55+);
+700
src-tauri/src/moderation.rs
···11+use super::auth::LazuriteOAuthSession;
22+use super::error::{AppError, Result};
33+use super::state::AppState;
44+use jacquard::api::app_bsky::labeler::get_services::GetServices;
55+use jacquard::api::app_bsky::labeler::get_services::GetServicesOutputViewsItem;
66+use jacquard::api::com_atproto::admin::RepoRef;
77+use jacquard::api::com_atproto::label::{Label, LabelValueDefinition};
88+use jacquard::api::com_atproto::moderation::create_report::{CreateReport, CreateReportSubject};
99+use jacquard::api::com_atproto::moderation::ReasonType;
1010+use jacquard::api::com_atproto::repo::strong_ref::StrongRef;
1111+use jacquard::moderation::moderate;
1212+use jacquard::moderation::{Blur, LabelPref, LabeledRecord, LabelerDefs, ModerationDecision, ModerationPrefs};
1313+use jacquard::types::aturi::AtUri;
1414+use jacquard::types::cid::Cid;
1515+use jacquard::types::did::Did;
1616+use jacquard::xrpc::{CallOptions, XrpcClient};
1717+use jacquard::{CowStr, IntoStatic};
1818+use rusqlite::{params, Connection, OptionalExtension};
1919+use serde::{Deserialize, Serialize};
2020+use std::collections::HashMap;
2121+use std::sync::Arc;
2222+use std::time::{SystemTime, UNIX_EPOCH};
2323+use tauri_plugin_log::log;
2424+2525+/// The built-in Bluesky safety labeler DID. Always included in the accept-labelers header.
2626+pub const BUILTIN_LABELER_DID: &str = "did:plc:ar7c4by46qjdydhdevvrndac";
2727+2828+/// How long to keep labeler policies in the local cache before re-fetching.
2929+const LABELER_CACHE_TTL_SECS: i64 = 3600;
3030+3131+/// Maximum number of user-subscribed labelers (Bluesky limit).
3232+pub const MAX_CUSTOM_LABELERS: usize = 20;
3333+3434+/// User's moderation preferences, persisted as JSON in `app_settings`.
3535+///
3636+/// Key in the table: `moderation_preferences::{did}`
3737+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
3838+#[serde(rename_all = "camelCase")]
3939+pub struct StoredModerationPrefs {
4040+ /// Whether adult-only content may be revealed by the user.
4141+ pub adult_content_enabled: bool,
4242+ /// DIDs of labelers the user has subscribed to (does not include the built-in labeler).
4343+ pub subscribed_labelers: Vec<String>,
4444+ /// Per-labeler label-visibility overrides.
4545+ ///
4646+ /// Map: labeler DID → (label identifier → "ignore" | "warn" | "hide")
4747+ pub label_preferences: HashMap<String, HashMap<String, String>>,
4848+}
4949+5050+/// The UI action the frontend should apply to a piece of content.
5151+#[derive(Debug, Clone, Serialize)]
5252+#[serde(rename_all = "camelCase")]
5353+pub struct ModerationUI {
5454+ /// Hide content completely.
5555+ pub filter: bool,
5656+ /// Blur level: "none" | "content" | "media"
5757+ pub blur: String,
5858+ /// Show a red alert badge.
5959+ pub alert: bool,
6060+ /// Show an informational badge.
6161+ pub inform: bool,
6262+ /// User cannot override the decision (e.g. legal takedown).
6363+ pub no_override: bool,
6464+}
6565+6666+impl From<ModerationDecision> for ModerationUI {
6767+ fn from(d: ModerationDecision) -> Self {
6868+ Self {
6969+ filter: d.filter,
7070+ blur: match d.blur {
7171+ Blur::None => "none".into(),
7272+ Blur::Content => "content".into(),
7373+ Blur::Media => "media".into(),
7474+ },
7575+ alert: d.alert,
7676+ inform: d.inform,
7777+ no_override: d.no_override,
7878+ }
7979+ }
8080+}
8181+8282+/// Input description of what to report.
8383+#[derive(Debug, Deserialize)]
8484+#[serde(rename_all = "camelCase", tag = "type")]
8585+pub enum ReportSubjectInput {
8686+ /// Report a whole account/profile.
8787+ Repo { did: String },
8888+ /// Report a specific record (post, etc.).
8989+ Record { uri: String, cid: String },
9090+}
9191+9292+pub fn prefs_key(did: &str) -> String {
9393+ format!("moderation_preferences::{did}")
9494+}
9595+9696+pub fn load_prefs(conn: &Connection, did: &str) -> Result<StoredModerationPrefs> {
9797+ let key = prefs_key(did);
9898+ let maybe_json: Option<String> = conn
9999+ .query_row("SELECT value FROM app_settings WHERE key = ?1", params![key], |row| {
100100+ row.get(0)
101101+ })
102102+ .optional()?;
103103+104104+ match maybe_json {
105105+ None => Ok(StoredModerationPrefs::default()),
106106+ Some(json) => serde_json::from_str(&json).map_err(|error| {
107107+ log::warn!("failed to deserialize moderation prefs for {did}: {error}");
108108+ AppError::SerdeJson(error)
109109+ }),
110110+ }
111111+}
112112+113113+pub fn save_prefs(conn: &Connection, did: &str, prefs: &StoredModerationPrefs) -> Result<()> {
114114+ let key = prefs_key(did);
115115+ let json = serde_json::to_string(prefs)?;
116116+ conn.execute(
117117+ "INSERT INTO app_settings(key, value) VALUES(?1, ?2)
118118+ ON CONFLICT(key) DO UPDATE SET value = excluded.value",
119119+ params![key, json],
120120+ )?;
121121+ Ok(())
122122+}
123123+124124+/// Load cached labeler policies. Returns `None` when absent or stale.
125125+pub fn load_labeler_cache(conn: &Connection, labeler_did: &str) -> Result<Option<Vec<LabelValueDefinition<'static>>>> {
126126+ let now = unix_now();
127127+ let row: Option<(String, i64)> = conn
128128+ .query_row(
129129+ "SELECT policies_json, fetched_at FROM labeler_cache WHERE labeler_did = ?1",
130130+ params![labeler_did],
131131+ |row| Ok((row.get(0)?, row.get(1)?)),
132132+ )
133133+ .optional()?;
134134+135135+ let Some((json, fetched_at)) = row else {
136136+ return Ok(None);
137137+ };
138138+139139+ if now - fetched_at > LABELER_CACHE_TTL_SECS {
140140+ log::debug!("labeler cache expired for {labeler_did}");
141141+ return Ok(None);
142142+ }
143143+144144+ let defs = serde_json::from_str::<Vec<LabelValueDefinition<'_>>>(&json).map_err(|error| {
145145+ log::warn!("failed to deserialize labeler cache for {labeler_did}: {error}");
146146+ AppError::SerdeJson(error)
147147+ })?;
148148+ let defs = defs
149149+ .into_iter()
150150+ .map(IntoStatic::into_static)
151151+ .collect::<Vec<LabelValueDefinition<'static>>>();
152152+153153+ Ok(Some(defs))
154154+}
155155+156156+pub fn store_labeler_cache(conn: &Connection, labeler_did: &str, defs: &[LabelValueDefinition<'_>]) -> Result<()> {
157157+ let json = serde_json::to_string(defs)?;
158158+ let now = unix_now();
159159+ conn.execute(
160160+ "INSERT INTO labeler_cache(labeler_did, policies_json, fetched_at) VALUES(?1, ?2, ?3)
161161+ ON CONFLICT(labeler_did) DO UPDATE SET policies_json = excluded.policies_json, fetched_at = excluded.fetched_at",
162162+ params![labeler_did, json, now],
163163+ )?;
164164+ Ok(())
165165+}
166166+167167+async fn get_session(state: &AppState) -> Result<Arc<LazuriteOAuthSession>> {
168168+ let did = state
169169+ .active_session
170170+ .read()
171171+ .map_err(|error| {
172172+ log::error!("active_session poisoned: {error}");
173173+ AppError::StatePoisoned("active_session")
174174+ })?
175175+ .as_ref()
176176+ .ok_or_else(|| AppError::Validation("no active account".into()))?
177177+ .did
178178+ .clone();
179179+180180+ state
181181+ .sessions
182182+ .read()
183183+ .map_err(|error| AppError::state_poisoned(format!("sessions poisoned: {error}")))?
184184+ .get(&did)
185185+ .cloned()
186186+ .ok_or_else(|| AppError::validation(format!("session not found for active account {did}")))
187187+}
188188+189189+fn active_did(state: &AppState) -> Result<String> {
190190+ state
191191+ .active_session
192192+ .read()
193193+ .map_err(|error| AppError::state_poisoned(format!("active_session poisoned: {error}")))?
194194+ .as_ref()
195195+ .map(|s| s.did.clone())
196196+ .ok_or_else(|| AppError::Validation("no active account".into()))
197197+}
198198+199199+/// Build the complete list of accepted labeler DIDs (built-in + user subscriptions).
200200+pub fn accepted_labeler_dids(prefs: &StoredModerationPrefs) -> Vec<String> {
201201+ let mut dids = vec![BUILTIN_LABELER_DID.to_string()];
202202+ for did in &prefs.subscribed_labelers {
203203+ if !dids.contains(did) {
204204+ dids.push(did.clone());
205205+ }
206206+ }
207207+ dids
208208+}
209209+210210+/// Apply the user's current labeler subscriptions as session-level `atproto-accept-labelers` headers.
211211+///
212212+/// This must be called after changing labeler subscriptions so that all subsequent API calls
213213+/// carry the correct header.
214214+pub async fn apply_labeler_headers(session: &LazuriteOAuthSession, prefs: &StoredModerationPrefs) {
215215+ let dids: Vec<CowStr<'static>> = accepted_labeler_dids(prefs)
216216+ .into_iter()
217217+ .map(|did| CowStr::from(did))
218218+ .collect();
219219+ let opts = CallOptions { atproto_accept_labelers: Some(dids), ..Default::default() };
220220+ session.set_options(opts).await;
221221+ log::debug!(
222222+ "updated atproto-accept-labelers to {} labeler(s)",
223223+ prefs.subscribed_labelers.len() + 1
224224+ );
225225+}
226226+227227+/// Fetch labeler policies from the Bluesky AppView for the given DIDs.
228228+///
229229+/// Returns a list of `(Did<'static>, Vec<LabelValueDefinition<'static>>)` pairs.
230230+/// Skips DIDs where the fetch fails (logged as warnings) so callers get partial results.
231231+///
232232+/// This function does **not** access the database — callers are responsible for caching.
233233+pub async fn fetch_labeler_policies_from_api(
234234+ session: &LazuriteOAuthSession, dids: &[String],
235235+) -> Vec<(Did<'static>, Vec<LabelValueDefinition<'static>>)> {
236236+ if dids.is_empty() {
237237+ return Vec::new();
238238+ }
239239+240240+ let parsed_dids: Vec<Did<'_>> = dids
241241+ .iter()
242242+ .filter_map(|s| {
243243+ Did::new(s)
244244+ .map_err(|error| {
245245+ log::warn!("skipping invalid labeler DID '{s}': {error}");
246246+ error
247247+ })
248248+ .ok()
249249+ })
250250+ .collect();
251251+252252+ if parsed_dids.is_empty() {
253253+ return Vec::new();
254254+ }
255255+256256+ log::info!("fetching policies for {} labeler(s) from API", parsed_dids.len());
257257+258258+ let request = GetServices::new().dids(parsed_dids).detailed(true).build();
259259+ let response = match session.send(request).await {
260260+ Ok(r) => r,
261261+ Err(error) => {
262262+ log::error!("failed to fetch labeler services: {error}");
263263+ return Vec::new();
264264+ }
265265+ };
266266+267267+ let output = match response.into_output() {
268268+ Ok(o) => o,
269269+ Err(error) => {
270270+ log::error!("failed to decode labeler services response: {error}");
271271+ return Vec::new();
272272+ }
273273+ };
274274+275275+ output
276276+ .views
277277+ .into_iter()
278278+ .filter_map(|view| {
279279+ let GetServicesOutputViewsItem::LabelerViewDetailed(detailed) = view else {
280280+ return None;
281281+ };
282282+ let did = detailed.creator.did.clone().into_static();
283283+ let label_defs = detailed
284284+ .policies
285285+ .label_value_definitions
286286+ .unwrap_or_default()
287287+ .into_iter()
288288+ .map(IntoStatic::into_static)
289289+ .collect::<Vec<_>>();
290290+ Some((did, label_defs))
291291+ })
292292+ .collect()
293293+}
294294+295295+/// Build `LabelerDefs` for the given DIDs, using the local cache where available
296296+/// and fetching from the API for any missing/stale entries.
297297+///
298298+/// The database connection is never held across an `await` point.
299299+pub async fn build_labeler_defs(
300300+ session: &LazuriteOAuthSession, state: &AppState, dids: &[String],
301301+) -> LabelerDefs<'static> {
302302+ let (mut defs, missing) = {
303303+ let Ok(conn) = state.auth_store.lock_connection() else {
304304+ log::error!("failed to lock DB for labeler cache read");
305305+ return LabelerDefs::new();
306306+ };
307307+308308+ let mut defs = LabelerDefs::new();
309309+ let mut missing: Vec<String> = Vec::new();
310310+311311+ for did_str in dids {
312312+ match load_labeler_cache(&conn, did_str) {
313313+ Ok(Some(cached_defs)) => {
314314+ if let Ok(did) = Did::new(did_str) {
315315+ defs.insert(did.into_static(), cached_defs);
316316+ }
317317+ }
318318+ Ok(None) => missing.push(did_str.clone()),
319319+ Err(error) => {
320320+ log::warn!("failed to read labeler cache for {did_str}: {error}");
321321+ missing.push(did_str.clone());
322322+ }
323323+ }
324324+ }
325325+326326+ (defs, missing)
327327+ };
328328+329329+ if !missing.is_empty() {
330330+ let fetched = fetch_labeler_policies_from_api(session, &missing).await;
331331+332332+ {
333333+ match state.auth_store.lock_connection() {
334334+ Ok(conn) => {
335335+ for (did, label_defs) in &fetched {
336336+ if let Err(error) = store_labeler_cache(&conn, did.as_str(), label_defs) {
337337+ log::warn!("failed to cache labeler policies for {}: {error}", did.as_str());
338338+ }
339339+ }
340340+ }
341341+ Err(error) => {
342342+ log::warn!("failed to lock DB for labeler cache write: {error}");
343343+ }
344344+ }
345345+ }
346346+347347+ for (did, label_defs) in fetched {
348348+ defs.insert(did, label_defs);
349349+ }
350350+ }
351351+352352+ defs
353353+}
354354+355355+/// Submit a moderation report to the Bluesky moderation service.
356356+pub async fn submit_report(
357357+ session: &LazuriteOAuthSession, subject: ReportSubjectInput, reason_type_str: String, reason: Option<String>,
358358+) -> Result<i64> {
359359+ let reason_type = ReasonType::from(reason_type_str);
360360+ let subject = match subject {
361361+ ReportSubjectInput::Repo { did } => {
362362+ let parsed_did = Did::new(&did)
363363+ .map_err(|_| AppError::validation("invalid DID in report subject"))?
364364+ .into_static();
365365+ let repo_ref = RepoRef::new().did(parsed_did).build();
366366+ CreateReportSubject::RepoRef(Box::new(repo_ref))
367367+ }
368368+ ReportSubjectInput::Record { uri, cid } => {
369369+ let parsed_uri = AtUri::new(&uri)
370370+ .map_err(|_| AppError::validation("invalid AT-URI in report subject"))?
371371+ .into_static();
372372+ let parsed_cid = Cid::str(&cid).into_static();
373373+ parsed_cid
374374+ .to_ipld()
375375+ .map_err(|error| AppError::validation(format!("invalid CID in report subject: {error}")))?;
376376+ let strong_ref = StrongRef::new().uri(parsed_uri).cid(parsed_cid).build();
377377+ CreateReportSubject::StrongRef(Box::new(strong_ref))
378378+ }
379379+ };
380380+381381+ let mut builder = CreateReport::new().reason_type(reason_type).subject(subject);
382382+ if let Some(reason_text) = reason {
383383+ builder = builder.reason(CowStr::from(reason_text));
384384+ }
385385+386386+ let request = builder.build();
387387+ let response = session.send(request).await.map_err(|error| {
388388+ log::error!("create_report API error: {error}");
389389+ AppError::validation("failed to submit report")
390390+ })?;
391391+392392+ let output = response.into_output().map_err(|error| {
393393+ log::error!("create_report response decode error: {error}");
394394+ AppError::validation("unexpected response from moderation service")
395395+ })?;
396396+397397+ log::info!("report submitted: id={}", output.id);
398398+ Ok(output.id)
399399+}
400400+401401+/// Convert stored prefs to the jacquard `ModerationPrefs` type.
402402+fn to_jacquard_prefs(prefs: &StoredModerationPrefs) -> ModerationPrefs<'static> {
403403+ let labelers = prefs
404404+ .label_preferences
405405+ .iter()
406406+ .filter_map(|(did_str, label_map)| {
407407+ let did = Did::new(did_str).ok()?.into_static();
408408+ let pref_map = label_map
409409+ .iter()
410410+ .map(|(label, vis)| {
411411+ let pref = parse_label_pref(vis);
412412+ (CowStr::from(label.clone()), pref)
413413+ })
414414+ .collect();
415415+ Some((did, pref_map))
416416+ })
417417+ .collect();
418418+419419+ ModerationPrefs { adult_content_enabled: prefs.adult_content_enabled, labels: HashMap::new(), labelers }
420420+}
421421+422422+fn parse_label_pref(s: &str) -> LabelPref {
423423+ match s {
424424+ "hide" => LabelPref::Hide,
425425+ "warn" => LabelPref::Warn,
426426+ _ => LabelPref::Ignore,
427427+ }
428428+}
429429+430430+/// Evaluate a JSON array of ATProto labels against the user's moderation preferences.
431431+///
432432+/// `labels_json` – JSON array of `com.atproto.label.defs#label` objects.
433433+/// `accepted_dids` – DIDs of labelers whose labels should be evaluated (built-in + subscribed).
434434+pub fn evaluate_labels(
435435+ labels_json: &str, prefs: &StoredModerationPrefs, defs: &LabelerDefs<'_>, accepted_dids: &[String],
436436+) -> Result<ModerationUI> {
437437+ let labels = serde_json::from_str::<Vec<Label<'_>>>(labels_json).map_err(|error| {
438438+ log::warn!("failed to deserialize labels: {error}");
439439+ AppError::validation("invalid labels format")
440440+ })?;
441441+ let labels = labels
442442+ .into_iter()
443443+ .map(IntoStatic::into_static)
444444+ .collect::<Vec<Label<'static>>>();
445445+446446+ let jacquard_prefs = to_jacquard_prefs(prefs);
447447+448448+ let accepted_labelers: Vec<Did<'_>> = accepted_dids.iter().filter_map(|s| Did::new(s).ok()).collect();
449449+450450+ let record = LabeledRecord { record: (), labels };
451451+ let decision = moderate(&record, &jacquard_prefs, defs, &accepted_labelers);
452452+ Ok(ModerationUI::from(decision))
453453+}
454454+455455+/// Load moderation preferences for the currently active account.
456456+pub fn get_prefs(state: &AppState) -> Result<StoredModerationPrefs> {
457457+ let did = active_did(state)?;
458458+ let conn = state.auth_store.lock_connection()?;
459459+ load_prefs(&conn, &did)
460460+}
461461+462462+/// Toggle adult-content access for the active account and persist.
463463+pub async fn set_adult_content(state: &AppState, enabled: bool) -> Result<()> {
464464+ let did = active_did(state)?;
465465+ let mut prefs = {
466466+ let conn = state.auth_store.lock_connection()?;
467467+ load_prefs(&conn, &did)?
468468+ };
469469+ prefs.adult_content_enabled = enabled;
470470+ let conn = state.auth_store.lock_connection()?;
471471+ save_prefs(&conn, &did, &prefs)
472472+}
473473+474474+/// Set the visibility preference for a specific label from a specific labeler.
475475+pub async fn set_label_pref(state: &AppState, labeler_did: String, label: String, visibility: String) -> Result<()> {
476476+ if !matches!(visibility.as_str(), "ignore" | "warn" | "hide") {
477477+ return Err(AppError::validation("visibility must be 'ignore', 'warn', or 'hide'"));
478478+ }
479479+480480+ let did = active_did(state)?;
481481+ let mut prefs = {
482482+ let conn = state.auth_store.lock_connection()?;
483483+ load_prefs(&conn, &did)?
484484+ };
485485+486486+ prefs
487487+ .label_preferences
488488+ .entry(labeler_did)
489489+ .or_default()
490490+ .insert(label, visibility);
491491+492492+ let conn = state.auth_store.lock_connection()?;
493493+ save_prefs(&conn, &did, &prefs)
494494+}
495495+496496+/// Subscribe the active account to a labeler and update the session headers.
497497+pub async fn subscribe_labeler(state: &AppState, labeler_did: String) -> Result<()> {
498498+ Did::new(&labeler_did).map_err(|_| AppError::validation("invalid labeler DID"))?;
499499+500500+ let did = active_did(state)?;
501501+ let mut prefs = {
502502+ let conn = state.auth_store.lock_connection()?;
503503+ load_prefs(&conn, &did)?
504504+ };
505505+506506+ if prefs.subscribed_labelers.contains(&labeler_did) {
507507+ return Ok(());
508508+ }
509509+510510+ if prefs.subscribed_labelers.len() >= MAX_CUSTOM_LABELERS {
511511+ return Err(AppError::validation(format!(
512512+ "you can subscribe to at most {MAX_CUSTOM_LABELERS} custom labelers"
513513+ )));
514514+ }
515515+516516+ prefs.subscribed_labelers.push(labeler_did.clone());
517517+ {
518518+ let conn = state.auth_store.lock_connection()?;
519519+ save_prefs(&conn, &did, &prefs)?;
520520+ }
521521+522522+ let session = get_session(state).await?;
523523+ apply_labeler_headers(&session, &prefs).await;
524524+525525+ let fetched = fetch_labeler_policies_from_api(&session, &[labeler_did]).await;
526526+ {
527527+ match state.auth_store.lock_connection() {
528528+ Ok(conn) => {
529529+ for (did, label_defs) in &fetched {
530530+ if let Err(error) = store_labeler_cache(&conn, did.as_str(), label_defs) {
531531+ log::warn!("failed to cache labeler policy after subscribe: {error}");
532532+ }
533533+ }
534534+ }
535535+ Err(error) => {
536536+ log::warn!("failed to lock DB for post-subscribe cache write: {error}");
537537+ }
538538+ }
539539+ }
540540+541541+ Ok(())
542542+}
543543+544544+/// Unsubscribe the active account from a labeler and update the session headers.
545545+pub async fn unsubscribe_labeler(state: &AppState, labeler_did: String) -> Result<()> {
546546+ let did = active_did(state)?;
547547+ let mut prefs = {
548548+ let conn = state.auth_store.lock_connection()?;
549549+ load_prefs(&conn, &did)?
550550+ };
551551+552552+ let before = prefs.subscribed_labelers.len();
553553+ prefs.subscribed_labelers.retain(|d| d != &labeler_did);
554554+555555+ if prefs.subscribed_labelers.len() == before {
556556+ return Ok(());
557557+ }
558558+559559+ prefs.label_preferences.remove(&labeler_did);
560560+ {
561561+ let conn = state.auth_store.lock_connection()?;
562562+ save_prefs(&conn, &did, &prefs)?;
563563+ }
564564+565565+ let session = get_session(state).await?;
566566+ apply_labeler_headers(&session, &prefs).await;
567567+568568+ Ok(())
569569+}
570570+571571+fn unix_now() -> i64 {
572572+ SystemTime::now()
573573+ .duration_since(UNIX_EPOCH)
574574+ .map(|d| d.as_secs() as i64)
575575+ .unwrap_or(0)
576576+}
577577+578578+/// Returns the distribution channel this binary was compiled for.
579579+///
580580+/// Controlled by the `DISTRIBUTION_CHANNEL` environment variable at compile time.
581581+/// Falls back to `"github"` if the variable was not set.
582582+pub fn distribution_channel() -> &'static str {
583583+ option_env!("DISTRIBUTION_CHANNEL").unwrap_or("github")
584584+}
585585+586586+#[cfg(test)]
587587+mod tests {
588588+ use super::*;
589589+ use rusqlite::Connection;
590590+591591+ fn in_memory_db() -> Connection {
592592+ let conn = Connection::open_in_memory().expect("open in-memory db");
593593+ conn.execute_batch(
594594+ "CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
595595+ CREATE TABLE labeler_cache (
596596+ labeler_did TEXT PRIMARY KEY,
597597+ policies_json TEXT NOT NULL,
598598+ fetched_at INTEGER NOT NULL
599599+ );",
600600+ )
601601+ .expect("create tables");
602602+ conn
603603+ }
604604+605605+ #[test]
606606+ fn moderation_prefs_round_trip() {
607607+ let conn = in_memory_db();
608608+ let did = "did:plc:abc123";
609609+610610+ let prefs = load_prefs(&conn, did).expect("load default prefs");
611611+ assert!(!prefs.adult_content_enabled);
612612+ assert!(prefs.subscribed_labelers.is_empty());
613613+614614+ let mut updated = prefs;
615615+ updated.adult_content_enabled = true;
616616+ updated.subscribed_labelers.push("did:plc:labeler1".into());
617617+ updated
618618+ .label_preferences
619619+ .entry("did:plc:labeler1".into())
620620+ .or_default()
621621+ .insert("porn".into(), "hide".into());
622622+623623+ save_prefs(&conn, did, &updated).expect("save prefs");
624624+625625+ let loaded = load_prefs(&conn, did).expect("load saved prefs");
626626+ assert!(loaded.adult_content_enabled);
627627+ assert_eq!(loaded.subscribed_labelers, vec!["did:plc:labeler1"]);
628628+ assert_eq!(loaded.label_preferences["did:plc:labeler1"]["porn"], "hide");
629629+ }
630630+631631+ #[test]
632632+ fn labeler_cache_round_trip() {
633633+ let conn = in_memory_db();
634634+ let did = "did:plc:labeler1";
635635+636636+ assert!(load_labeler_cache(&conn, did).expect("load empty cache").is_none());
637637+638638+ store_labeler_cache(&conn, did, &[]).expect("store empty defs");
639639+ let cached = load_labeler_cache(&conn, did).expect("load cached defs");
640640+ assert!(cached.is_some());
641641+ }
642642+643643+ #[test]
644644+ fn labeler_cache_staleness() {
645645+ let conn = in_memory_db();
646646+ let did = "did:plc:labeler_stale";
647647+ let old_ts = unix_now() - LABELER_CACHE_TTL_SECS - 1;
648648+649649+ conn.execute(
650650+ "INSERT INTO labeler_cache(labeler_did, policies_json, fetched_at) VALUES(?1, '[]', ?2)",
651651+ params![did, old_ts],
652652+ )
653653+ .expect("insert stale cache");
654654+655655+ assert!(
656656+ load_labeler_cache(&conn, did).expect("load stale").is_none(),
657657+ "stale cache entry should be treated as missing"
658658+ );
659659+ }
660660+661661+ #[test]
662662+ fn accepted_labeler_dids_includes_builtin() {
663663+ let prefs = StoredModerationPrefs { subscribed_labelers: vec!["did:plc:custom".into()], ..Default::default() };
664664+ let dids = accepted_labeler_dids(&prefs);
665665+ assert!(dids.contains(&BUILTIN_LABELER_DID.to_string()));
666666+ assert!(dids.contains(&"did:plc:custom".to_string()));
667667+ }
668668+669669+ #[test]
670670+ fn accepted_labeler_dids_no_duplicates() {
671671+ let prefs =
672672+ StoredModerationPrefs { subscribed_labelers: vec![BUILTIN_LABELER_DID.into()], ..Default::default() };
673673+ let dids = accepted_labeler_dids(&prefs);
674674+ let count = dids.iter().filter(|d| d.as_str() == BUILTIN_LABELER_DID).count();
675675+ assert_eq!(count, 1, "builtin labeler should not be duplicated");
676676+ }
677677+678678+ #[test]
679679+ fn distribution_channel_defaults_to_github() {
680680+ let channel = distribution_channel();
681681+ assert!(!channel.is_empty());
682682+ }
683683+684684+ #[test]
685685+ fn evaluate_labels_empty_returns_no_moderation() {
686686+ let prefs = StoredModerationPrefs::default();
687687+ let defs = LabelerDefs::new();
688688+ let accepted: Vec<String> = vec![];
689689+ let ui = evaluate_labels("[]", &prefs, &defs, &accepted).expect("evaluate");
690690+ assert!(!ui.filter);
691691+ assert_eq!(ui.blur, "none");
692692+ assert!(!ui.alert);
693693+ assert!(!ui.inform);
694694+ }
695695+696696+ #[test]
697697+ fn prefs_key_format() {
698698+ assert_eq!(prefs_key("did:plc:abc"), "moderation_preferences::did:plc:abc");
699699+ }
700700+}