diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs index ab1fc319c11e3b55a78852dd197975cfbda09b15..d13259617d56ce2f84c8c1650b2b81f28d5002f0 100644 --- a/server/src/api/ui_html.rs +++ b/server/src/api/ui_html.rs @@ -8,12 +8,13 @@ use std::collections::HashMap; use crate::{ auth::{ - alias_status_js, alias_redirect_js, config, login_redirect_js, oauth, redirect_js, resolve_vote_actor, + alias_redirect_js, alias_status_js, config, login_redirect_js, oauth, redirect_js, + resolve_vote_actor, session::{load_valid_session, session_has_pseudonym, session_id_from_jar}, }, fetch, html::{input_panel, js_string_literal, ranking_panel, JsBuilder}, - nsfw::{item_is_nsfw_in_store, nsfw_allowed}, + nsfw::{item_is_visible, item_nsfw_status_in_store, nsfw_allowed}, parser::parse_reddit_url, path_types::ItemId, state::{parse_item_param, AppState}, @@ -87,14 +88,12 @@ pub async fn post_ui_html( let left = parse_item_param(&a); let right = parse_item_param(&b); // Strict boundary: refuse votes that would surface NSFW without opt-in. - if !nsfw_ok { - let store = &state.projection_store; - if item_is_nsfw_in_store(store, &parent) - || item_is_nsfw_in_store(store, &left) - || item_is_nsfw_in_store(store, &right) - { - return ui_js_warn("NSFW opt-in required").into_response(); - } + let store = &state.projection_store; + if !item_is_visible(item_nsfw_status_in_store(store, &parent), nsfw_ok) + || !item_is_visible(item_nsfw_status_in_store(store, &left), nsfw_ok) + || !item_is_visible(item_nsfw_status_in_store(store, &right), nsfw_ok) + { + return ui_js_warn("NSFW opt-in or Reddit classification required").into_response(); } if let Err(e) = state .record_vote(&parent, &a, &b, ratio_left, ratio_right, &actor) diff --git a/server/src/events.rs b/server/src/events.rs index 8d4c1e260f9c034f5f7ec07484dda17efd69ecff..e9d6ad9c905e1e72e9da1eb7482e1fbfb4c6ce09 100644 --- a/server/src/events.rs +++ b/server/src/events.rs @@ -31,6 +31,7 @@ pub fn event_timestamp(event: &Event) -> i64 { match event { Event::VoteRecorded { ts, .. } => *ts, Event::NodeEnsured { .. } => crate::fetch::now_ms(), + Event::NsfwClassified { .. } => crate::fetch::now_ms(), Event::PrincipalCreated { ts, .. } => *ts, Event::OauthLinked { ts, .. } => *ts, Event::PseudonymClaimed { ts, .. } => *ts, @@ -61,6 +62,10 @@ pub enum Event { /// Register a node path in the fractal tree (no external fetch). NodeEnsured { id: String }, + /// Durable Reddit safety classification, stored separately from expiring + /// display content so the NSFW wall survives eviction and projection replay. + NsfwClassified { id: String, over_18: bool }, + /// New trust anchor (first identity event for a human). PrincipalCreated { uuid: String, ts: i64 }, diff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs index 4c5d4148341c12eef6dfbb61f9dce3073d35c650..edbbb71b8ef89aa8172993b9067effc9da2b1f76 100644 --- a/server/src/fetch/html.rs +++ b/server/src/fetch/html.rs @@ -5,7 +5,7 @@ use maud::{html, Markup}; use crate::{ form_template::template_json_compact, html::sanitize::entity_body_html, - nsfw::entity_is_nsfw, + nsfw::node_is_nsfw, path_types::ItemId, reddit::{is_children_fetchable, is_fetchable, is_ranked_fetchable}, reducer::NodeState, @@ -25,21 +25,24 @@ pub fn entity_panel(node: &NodeState, nsfw_ok: bool) -> Markup { html! { @if let Some(data) = &node.data { div class="entity-card" { - h2 { (data.title) } - @if let Some(author) = &data.author { - p class="muted small" { "by " (author) } - } - @if entity_is_nsfw(data) { - p class="muted small" { - span class="nsfw-badge" { "NSFW" } - } - } - @if entity_is_nsfw(data) && !nsfw_ok { + @if node_is_nsfw(node) && !nsfw_ok { div class="nsfw-gate" data-testid="nsfw-gate" { + span class="nsfw-badge" { "NSFW" } p { "NSFW content is hidden until you opt in." } } - } @else if let Some(body) = &data.body_html { - div class="entity-body" { (maud::PreEscaped(entity_body_html(body))) } + } @else { + h2 { (data.title) } + @if let Some(author) = &data.author { + p class="muted small" { "by " (author) } + } + @if node_is_nsfw(node) { + p class="muted small" { + span class="nsfw-badge" { "NSFW" } + } + } + @if let Some(body) = &data.body_html { + div class="entity-body" { (maud::PreEscaped(entity_body_html(body))) } + } } } } @@ -72,8 +75,7 @@ pub fn fetch_entity_panel(item: &ItemId, node: &NodeState, fetching: bool) -> Ma let has_data = node.data.is_some(); let self_ok = is_fetchable(item); let children_ok = is_children_fetchable(item); - let ranked_ok = - is_ranked_fetchable(item) && node.children.iter().any(is_reddit_post); + let ranked_ok = is_ranked_fetchable(item) && node.children.iter().any(is_reddit_post); if !self_ok && !children_ok && !ranked_ok { return html! {}; } diff --git a/server/src/nsfw.rs b/server/src/nsfw.rs index 0380d515cd278f4b1bd15b695dfe7e6781c9b8fc..eb6f4c11995b56a32187a8a0a43b90048e877b46 100644 --- a/server/src/nsfw.rs +++ b/server/src/nsfw.rs @@ -10,7 +10,7 @@ use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use crate::{ auth::config, path_types::ItemId, - reducer::{EntityData, GlobalTree}, + reducer::{EntityData, GlobalTree, NodeState}, }; pub const NSFW_COOKIE: &str = "sorter2_nsfw"; @@ -49,47 +49,107 @@ pub fn entity_is_nsfw(data: &EntityData) -> bool { data.over_18 } -/// True if this item or any loaded ancestor is marked NSFW. -/// -/// Posts imported from a listing carry their own `over_18` even when the -/// parent subreddit is still a data-less stub. When the parent *is* known -/// NSFW, children inherit that status here even if their own flag was lost -/// (e.g. after ephemeral eviction). -pub fn item_is_nsfw(tree: &GlobalTree, id: &ItemId) -> bool { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NsfwStatus { + Safe, + Nsfw, + /// Reddit entity metadata has not been fetched/classified yet. + Unknown, +} + +pub fn node_nsfw_status(node: &NodeState) -> NsfwStatus { + if node.nsfw_classification == Some(true) || node.data.as_ref().is_some_and(entity_is_nsfw) { + NsfwStatus::Nsfw + } else if node.nsfw_classification == Some(false) || node.data.is_some() { + NsfwStatus::Safe + } else { + NsfwStatus::Unknown + } +} + +pub fn node_is_nsfw(node: &NodeState) -> bool { + node_nsfw_status(node) == NsfwStatus::Nsfw +} + +fn needs_reddit_classification(id: &ItemId) -> bool { + crate::reddit::is_fetchable(id) +} + +/// Classification inherited from this Reddit entity and its concrete Reddit +/// ancestors. Unknown metadata fails closed at listing boundaries. +pub fn item_nsfw_status(tree: &GlobalTree, id: &ItemId) -> NsfwStatus { + if !needs_reddit_classification(id) { + return NsfwStatus::Safe; + } + + let mut unknown = false; let mut cur = Some(id.clone()); while let Some(item) = cur { - if tree - .get(&item) - .and_then(|n| n.data.as_ref()) - .is_some_and(entity_is_nsfw) - { - return true; + if needs_reddit_classification(&item) { + match tree.get(&item).map(node_nsfw_status) { + Some(NsfwStatus::Nsfw) => return NsfwStatus::Nsfw, + Some(NsfwStatus::Safe) => {} + Some(NsfwStatus::Unknown) | None => unknown = true, + } } cur = item.parent(); } - false + if unknown { + NsfwStatus::Unknown + } else { + NsfwStatus::Safe + } +} + +pub fn item_is_nsfw(tree: &GlobalTree, id: &ItemId) -> bool { + item_nsfw_status(tree, id) == NsfwStatus::Nsfw } /// Lightweight NSFW check that walks ancestors via `load_node` instead of /// materializing a full scope tree (used on the vote write path). -pub fn item_is_nsfw_in_store( +pub fn item_nsfw_status_in_store( store: &crate::projection_store::ProjectionStore, id: &ItemId, -) -> bool { +) -> NsfwStatus { + if !needs_reddit_classification(id) { + return NsfwStatus::Safe; + } + + let mut unknown = false; let mut cur = Some(id.clone()); while let Some(item) = cur { - if store - .load_node(&item) - .ok() - .flatten() - .and_then(|n| n.data) - .is_some_and(|d| d.over_18) - { - return true; + if needs_reddit_classification(&item) { + match store.load_node(&item) { + Ok(Some(node)) => match node_nsfw_status(&node) { + NsfwStatus::Nsfw => return NsfwStatus::Nsfw, + NsfwStatus::Safe => {} + NsfwStatus::Unknown => unknown = true, + }, + Ok(None) | Err(_) => unknown = true, + } } cur = item.parent(); } - false + if unknown { + NsfwStatus::Unknown + } else { + NsfwStatus::Safe + } +} + +pub fn item_is_nsfw_in_store( + store: &crate::projection_store::ProjectionStore, + id: &ItemId, +) -> bool { + item_nsfw_status_in_store(store, id) == NsfwStatus::Nsfw +} + +pub fn item_is_visible(status: NsfwStatus, nsfw_ok: bool) -> bool { + match status { + NsfwStatus::Safe => true, + NsfwStatus::Nsfw => nsfw_ok, + NsfwStatus::Unknown => false, + } } /// Children visible in the current dimension (SFW-only unless `nsfw_ok`). @@ -100,7 +160,7 @@ pub fn visible_children(tree: &GlobalTree, parent: &ItemId, nsfw_ok: bool) -> Ve let mut children: Vec = node .children .iter() - .filter(|c| nsfw_ok || !item_is_nsfw(tree, c)) + .filter(|c| item_is_visible(item_nsfw_status(tree, c), nsfw_ok)) .cloned() .collect(); children.sort_by(|a, b| a.as_str().cmp(b.as_str())); @@ -139,8 +199,7 @@ mod tests { #[test] fn post_over_18_is_nsfw_even_when_parent_is_stub() { let parent = ItemId::from_url("https://reddit.com/r/nsfw").unwrap(); - let post = - ItemId::from_url("https://reddit.com/r/nsfw/comments/abc/adult").unwrap(); + let post = ItemId::from_url("https://reddit.com/r/nsfw/comments/abc/adult").unwrap(); let mut tree = GlobalTree::new(); tree.nodes.insert( parent.clone(), @@ -166,8 +225,7 @@ mod tests { #[test] fn child_inherits_nsfw_from_fetched_parent() { let parent = ItemId::from_url("https://reddit.com/r/gonewild").unwrap(); - let post = - ItemId::from_url("https://reddit.com/r/gonewild/comments/abc/x").unwrap(); + let post = ItemId::from_url("https://reddit.com/r/gonewild/comments/abc/x").unwrap(); let mut tree = GlobalTree::new(); tree.nodes.insert( parent.clone(), @@ -189,6 +247,63 @@ mod tests { assert!(item_is_nsfw(&tree, &post)); } + #[test] + fn child_inherits_durable_nsfw_after_parent_content_is_evicted() { + let parent = ItemId::from_url("https://reddit.com/r/gonewild").unwrap(); + let post = ItemId::from_url("https://reddit.com/r/gonewild/comments/abc/x").unwrap(); + let mut tree = GlobalTree::new(); + tree.nodes.insert( + parent.clone(), + NodeState { + id: parent.clone(), + data: None, + nsfw_classification: Some(true), + children: [post.clone()].into_iter().collect(), + ..Default::default() + }, + ); + tree.nodes.insert( + post.clone(), + NodeState { + id: post.clone(), + data: None, + nsfw_classification: Some(false), + ..Default::default() + }, + ); + + assert_eq!(item_nsfw_status(&tree, &post), NsfwStatus::Nsfw); + assert!(visible_children(&tree, &parent, false).is_empty()); + assert_eq!(visible_children(&tree, &parent, true), vec![post]); + } + + #[test] + fn unknown_reddit_children_fail_closed_even_with_opt_in() { + let parent = ItemId::from_url("https://reddit.com/r/mixed").unwrap(); + let post = ItemId::from_url("https://reddit.com/r/mixed/comments/abc/x").unwrap(); + let mut tree = GlobalTree::new(); + tree.nodes.insert( + parent.clone(), + NodeState { + id: parent.clone(), + nsfw_classification: Some(false), + children: [post.clone()].into_iter().collect(), + ..Default::default() + }, + ); + tree.nodes.insert( + post.clone(), + NodeState { + id: post.clone(), + ..Default::default() + }, + ); + + assert_eq!(item_nsfw_status(&tree, &post), NsfwStatus::Unknown); + assert!(visible_children(&tree, &parent, false).is_empty()); + assert!(visible_children(&tree, &parent, true).is_empty()); + } + #[test] fn visible_children_hides_nsfw_unless_opted_in() { let parent = ItemId::from_url("https://reddit.com/r/mixed").unwrap(); diff --git a/server/src/projection_apply.rs b/server/src/projection_apply.rs index f50458c2ff3c447a3cfd28adb298e6883a2da4e9..c3ae0d8ba78c1f9971fa7159a0fd7beeeb3c22dd 100644 --- a/server/src/projection_apply.rs +++ b/server/src/projection_apply.rs @@ -7,14 +7,15 @@ use std::collections::HashMap; use crate::{ + auth::identity::{trust_weight_after_link, BASE_TRUST_WEIGHT}, event_log::EventLogError, events::{Event, EventRecord}, - auth::identity::{trust_weight_after_link, BASE_TRUST_WEIGHT}, path_types::ItemId, projection_store::ProjectionStore, reducer::VoteData, storage_schema::{ - ensure_path_writes, oauth_link_key, pseudonym_owner, vote_writes, Store, StoreFields, + ensure_path_writes, nsfw_classification_writes, oauth_link_key, pseudonym_owner, + vote_writes, Store, StoreFields, }, }; @@ -68,16 +69,11 @@ pub fn apply_records( "invalid vote event: zero weights ({a} vs {b})" ))); } - let vote = VoteData::from_event( - *ts, - a, - b, - left, - right, - pseudonym.clone(), - *trust_weight, - ) - .ok_or_else(|| EventLogError::Apply(format!("invalid vote event: {a} vs {b}")))?; + let vote = + VoteData::from_event(*ts, a, b, left, right, pseudonym.clone(), *trust_weight) + .ok_or_else(|| { + EventLogError::Apply(format!("invalid vote event: {a} vs {b}")) + })?; let actor_uuid = crate::identity::resolve_actor_uuid(db, pseudonym) .map_err(|e| EventLogError::Apply(e))?; let parent = parent_from_event_scope(scope); @@ -88,6 +84,10 @@ pub fn apply_records( let parsed = parse_event_id(id)?; ensure_path_writes(&mut batch, &parsed); } + Event::NsfwClassified { id, over_18 } => { + let parsed = parse_event_id(id)?; + nsfw_classification_writes(&mut batch, &parsed, *over_18); + } Event::PrincipalCreated { uuid, .. } => { pending_weights.insert(uuid.clone(), BASE_TRUST_WEIGHT); batch.write( @@ -131,15 +131,12 @@ pub fn apply_records( .unwrap_or(BASE_TRUST_WEIGHT); let next = trust_weight_after_link(current); pending_weights.insert(uuid.clone(), next); - batch.write( - Store::root() - .user_weights() - .key(&uuid.clone()) - .set(&next), - ); + batch.write(Store::root().user_weights().key(&uuid.clone()).set(&next)); } } - Event::PseudonymClaimed { uuid, pseudonym, .. } => { + Event::PseudonymClaimed { + uuid, pseudonym, .. + } => { if let Some(owner) = pseudonym_owner(db, pseudonym) .map_err(|e| EventLogError::Apply(e.to_string()))? { @@ -149,12 +146,7 @@ pub fn apply_records( ))); } } else { - batch.write( - Store::root() - .pseudonyms() - .key(&pseudonym.clone()) - .set(uuid), - ); + batch.write(Store::root().pseudonyms().key(&pseudonym.clone()).set(uuid)); batch .push( &Store::root().user_pseudonyms().key(&uuid.clone()), diff --git a/server/src/projection_store.rs b/server/src/projection_store.rs index a34905787102dbbe5249abc1fbdf505e8a5439a6..ff8ef18697af455060dc56716388d7e84c288d44 100644 --- a/server/src/projection_store.rs +++ b/server/src/projection_store.rs @@ -11,14 +11,14 @@ use crate::{ path_types::ItemId, reducer::{EntityData, GlobalTree, NodeState}, storage_schema::{ - entity_content_clear_writes, entity_content_writes, load_node_state, node, NodeSchemaFields, - Store, StoreFields, + entity_content_clear_writes, entity_content_writes, load_node_state, node, + NodeSchemaFields, Store, StoreFields, }, }; const PROJECTION_CURSOR_KEY: &str = "cursor"; const PROJECTION_SCHEMA_KEY: &str = "schema_version"; -const PROJECTION_SCHEMA_VERSION: u64 = 6; +const PROJECTION_SCHEMA_VERSION: u64 = 7; #[derive(Debug, thiserror::Error)] pub enum ProjectionStoreError { @@ -51,8 +51,7 @@ impl ProjectionStore { if version != Some(PROJECTION_SCHEMA_VERSION) { store.reset()?; } - crate::identity::seed_default_pseudonym(db) - .map_err(ProjectionStoreError::Durable)?; + crate::identity::seed_default_pseudonym(db).map_err(ProjectionStoreError::Durable)?; Ok(store) } @@ -141,6 +140,16 @@ impl ProjectionStore { pub fn scope_tree(&self, id: &ItemId) -> Result { let mut tree = GlobalTree::new(); self.hydrate_scope(&mut tree, id)?; + // Classification is inherited from Reddit ancestors. Hydrate the + // breadcrumb chain as well as the requested node and its children so + // every read/render path sees the same wall. + let mut ancestor = id.parent(); + while let Some(parent) = ancestor { + if let Some(node_state) = self.load_node(&parent)? { + tree.nodes.insert(parent.clone(), node_state); + } + ancestor = parent.parent(); + } Ok(tree) } @@ -249,6 +258,25 @@ mod tests { let db = Db::open(tmp.path()).unwrap(); let store = ProjectionStore::from_db(&db).unwrap(); let id = ItemId::from_url("https://reddit.com/r/rust").unwrap(); + projection_apply::apply_records( + &store, + &[ + record( + 1, + Event::NodeEnsured { + id: id.as_str().to_string(), + }, + ), + record( + 2, + Event::NsfwClassified { + id: id.as_str().to_string(), + over_18: true, + }, + ), + ], + ) + .unwrap(); store .put_ephemeral_content( &id, @@ -266,6 +294,8 @@ mod tests { .unwrap(); assert!(store.load_node(&id).unwrap().unwrap().data.is_some()); assert_eq!(store.evict_content_older_than(2_000).unwrap(), 1); - assert!(store.load_node(&id).unwrap().unwrap().data.is_none()); + let node = store.load_node(&id).unwrap().unwrap(); + assert!(node.data.is_none()); + assert_eq!(node.nsfw_classification, Some(true)); } } diff --git a/server/src/reddit.rs b/server/src/reddit.rs index eb8d34adf9f786413a04cb505f766530206576a5..c82acd4f42d6073ec2110179358721c7bb312462 100644 --- a/server/src/reddit.rs +++ b/server/src/reddit.rs @@ -9,8 +9,8 @@ use serde_json::Value; use tokio::sync::{mpsc, oneshot}; use crate::{ - events::Event, fetch::now_ms, journal::JournalClient, - path_types::ItemId, projection_store::ProjectionStore, + events::Event, fetch::now_ms, journal::JournalClient, nsfw::node_is_nsfw, path_types::ItemId, + projection_store::ProjectionStore, }; /// Reddit display content must not be retained longer than this (API policy). @@ -233,13 +233,16 @@ async fn import_fetched_payload( .load_node(fetch_id) .ok() .flatten() - .and_then(|n| n.data) - .map(|d| d.over_18) + .map(|n| node_is_nsfw(&n)) .unwrap_or(false), FetchKind::SelfEntity => false, }; + let mut events = Vec::with_capacity(imports.len() * 2); for (id, child_payload) in &imports { + events.push(Event::NodeEnsured { + id: id.as_str().to_string(), + }); if let Some(mut view) = entity_view_from_payload(id, child_payload) { if parent_nsfw { view.over_18 = true; @@ -247,16 +250,14 @@ async fn import_fetched_payload( projection_store .put_ephemeral_content(id, &view, fetched_at) .map_err(|e| e.to_string())?; + events.push(Event::NsfwClassified { + id: id.as_str().to_string(), + over_18: view.over_18, + }); } } - let events: Vec = imports - .iter() - .map(|(id, _)| Event::NodeEnsured { - id: id.as_str().to_string(), - }) - .collect(); - let written = events.len(); + let written = imports.len(); if !events.is_empty() { journal.append_many(events).await?; } @@ -264,13 +265,15 @@ async fn import_fetched_payload( } /// Refresh ephemeral display content for posts already under `parent`. -/// Does not append `NodeEnsured` (structure already persisted). Clears cache for -/// requested posts missing from Reddit's `/api/info` response. -fn import_ranked_payload( +/// Does not append `NodeEnsured` (structure already persisted), but persists +/// refreshed safety classifications. Clears cache for requested posts missing +/// from Reddit's `/api/info` response. +async fn import_ranked_payload( parent: &ItemId, requested: &[ItemId], payloads: &[Value], projection_store: &ProjectionStore, + journal: &JournalClient, ) -> Result { let requested_set: HashSet = requested.iter().cloned().collect(); let fetched_at = now_ms(); @@ -279,9 +282,9 @@ fn import_ranked_payload( .load_node(parent) .ok() .flatten() - .and_then(|n| n.data) - .map(|d| d.over_18) + .map(|n| node_is_nsfw(&n)) .unwrap_or(false); + let mut classification_events = Vec::new(); for payload in payloads { for (id, child_payload) in parse_children(&ItemId::root(), payload) { @@ -295,6 +298,10 @@ fn import_ranked_payload( projection_store .put_ephemeral_content(&id, &view, fetched_at) .map_err(|e| e.to_string())?; + classification_events.push(Event::NsfwClassified { + id: id.as_str().to_string(), + over_18: view.over_18, + }); } found.insert(id); } @@ -308,6 +315,10 @@ fn import_ranked_payload( } } + if !classification_events.is_empty() { + journal.append_many(classification_events).await?; + } + Ok(found.len()) } @@ -318,7 +329,9 @@ fn ranked_posts_under(parent: &ItemId, projection_store: &ProjectionStore) -> Ve let mut posts: Vec = node .children .into_iter() - .filter(|c| crate::render::reddit::is_reddit_post(c) && reddit_post_fullname_id(c).is_some()) + .filter(|c| { + crate::render::reddit::is_reddit_post(c) && reddit_post_fullname_id(c).is_some() + }) .collect(); posts.sort_by(|a, b| a.as_str().cmp(b.as_str())); posts @@ -380,7 +393,14 @@ async fn reddit_worker( continue; } - let urls = map_info_urls(&posts, if creds.is_some() { &oauth_api_base } else { &api_base }); + let urls = map_info_urls( + &posts, + if creds.is_some() { + &oauth_api_base + } else { + &api_base + }, + ); let mut payloads = Vec::new(); let mut ranked_err: Option = None; @@ -439,7 +459,15 @@ async fn reddit_worker( if let Some(err) = ranked_err { notify(done, err); } else { - match import_ranked_payload(&fetch_id, &posts, &payloads, &projection_store) { + match import_ranked_payload( + &fetch_id, + &posts, + &payloads, + &projection_store, + &journal, + ) + .await + { Err(e) => { tracing::warn!(item = %fetch_id, err = %e, "reddit ranked import failed"); notify(done, FetchJobResult::Failed(e)); @@ -550,9 +578,14 @@ async fn reddit_worker( enum FetchOutcome { Payload(Value), NotFound, - RateLimited { reset_secs: u64 }, + RateLimited { + reset_secs: u64, + }, /// Bearer rejected — caller should drop the cached token and retry once. - AuthRejected { status: StatusCode, detail: String }, + AuthRejected { + status: StatusCode, + detail: String, + }, } async fn fetch_with_oauth( @@ -1111,8 +1144,7 @@ mod tests { fn parse_post_listing_extracts_thumb_and_full_preview() { let json = include_str!("../../test/fixtures/reddit/post_preview.json"); let v: Value = serde_json::from_str(json).unwrap(); - let id = - ItemId::from_url("https://reddit.com/r/nsfw/comments/1tpy6a1/angel_eyes").unwrap(); + let id = ItemId::from_url("https://reddit.com/r/nsfw/comments/1tpy6a1/angel_eyes").unwrap(); let entity = entity_view_from_payload(&id, &v).unwrap(); assert_eq!(entity.title, "Angel Eyes"); assert!(entity.over_18); @@ -1147,6 +1179,39 @@ mod tests { ); } + #[tokio::test] + async fn nsfw_import_persists_classification_in_projection_and_event_log() { + let tmp = tempfile::tempdir().unwrap(); + let db = durable::Db::open(tmp.path().join("store")).unwrap(); + let store = ProjectionStore::from_db(&db).unwrap(); + let log_path = tmp.path().join("events.jsonl"); + let log = std::sync::Arc::new(crate::event_log::EventLog::new(&log_path)); + let journal = JournalClient::spawn(log, store.clone(), 1); + let id = ItemId::from_url("https://reddit.com/r/nsfw/comments/abc/adult_post").unwrap(); + let payload = serde_json::json!({ + "kind": "t3", + "data": { + "title": "adult post", + "over_18": true, + "thumbnail": "https://example.com/thumb.jpg" + } + }); + + let imported = + import_fetched_payload(FetchKind::SelfEntity, &id, payload, &store, &journal) + .await + .unwrap(); + + assert_eq!(imported, 1); + let node = store.load_node(&id).unwrap().unwrap(); + assert_eq!(node.nsfw_classification, Some(true)); + assert!(node.data.unwrap().over_18); + let jsonl = std::fs::read_to_string(log_path).unwrap(); + assert!(jsonl.contains(r#""type":"node_ensured""#)); + assert!(jsonl.contains(r#""type":"nsfw_classified""#)); + assert!(jsonl.contains(r#""over_18":true"#)); + } + #[test] fn parse_post_thumbnail_nsfw_sentinel_marks_over_18() { let v = serde_json::json!({ diff --git a/server/src/reducer.rs b/server/src/reducer.rs index 05167564b0a089b76e407ebd760e6d6df8605c0e..ea9da30cff3e287c847640571e2f6eb90ebf0702 100644 --- a/server/src/reducer.rs +++ b/server/src/reducer.rs @@ -101,6 +101,10 @@ pub struct NodeState { pub id: ItemId, /// Ephemeral display view (Reddit title/author/etc.; not event-logged). pub data: Option, + /// Durable Reddit safety classification. `None` means the node has not yet + /// been classified and must not appear in listings. + #[serde(default)] + pub nsfw_classification: Option, pub children: HashSet, pub votes: ScopeVotes, } @@ -215,16 +219,7 @@ mod tests { #[test] fn from_event_rejects_same_item() { - assert!(VoteData::from_event( - 1, - "a", - "a", - 2, - 1, - "anon".into(), - 1.0 - ) - .is_none()); + assert!(VoteData::from_event(1, "a", "a", 2, 1, "anon".into(), 1.0).is_none()); } #[test] diff --git a/server/src/render/reddit.rs b/server/src/render/reddit.rs index b70e223d4658057b5f57934d1fb922335ddd52e8..16cfe007925a08ecbad6299535026a78375748f2 100644 --- a/server/src/render/reddit.rs +++ b/server/src/render/reddit.rs @@ -4,7 +4,7 @@ use maud::{html, Markup}; use crate::{ html::sanitize::entity_body_html, - nsfw::entity_is_nsfw, + nsfw::{item_nsfw_status, node_is_nsfw, NsfwStatus}, path_types::ItemId, reducer::{EntityData, GlobalTree, NodeState}, }; @@ -22,7 +22,7 @@ pub fn entity_markup(node: &NodeState, nsfw_ok: bool) -> Option { return None; } let data = node.data.as_ref()?; - Some(post_entity_card(data, nsfw_ok)) + Some(post_entity_card(data, node_is_nsfw(node), nsfw_ok)) } /// One row in a parent ranking list (thumbnail + title). @@ -32,40 +32,49 @@ pub fn child_row_markup(tree: &GlobalTree, id: &ItemId, href: &str) -> Option Markup { +fn post_entity_card(data: &EntityData, is_nsfw: bool, nsfw_ok: bool) -> Markup { let image = data.image_url.as_ref().or(data.thumb_url.as_ref()); - let gated = entity_is_nsfw(data) && !nsfw_ok; + let gated = is_nsfw && !nsfw_ok; html! { div class="entity-card reddit-post" { - h2 { (data.title) } - @if let Some(author) = &data.author { - p class="muted small" { "by " (author) } - } - @if entity_is_nsfw(data) { - p class="muted small" { - span class="nsfw-badge" { "NSFW" } - " adult Reddit content" - } - } @if gated { div class="nsfw-gate" data-testid="nsfw-gate" { - p { "This post is in the NSFW dimension. Media, body text, and links stay hidden until you opt in." } + span class="nsfw-badge" { "NSFW" } + p { "This post is in the NSFW dimension. Its title, media, body text, and links stay hidden until you opt in." } } } @else { + h2 { (data.title) } + @if let Some(author) = &data.author { + p class="muted small" { "by " (author) } + } + @if is_nsfw { + p class="muted small" { + span class="nsfw-badge" { "NSFW" } + " adult Reddit content" + } + } @if let Some(url) = &data.link_url { p class="reddit-post-url muted small" { a href=(url) rel="noopener noreferrer" { (url) } @@ -109,6 +118,7 @@ mod tests { let html = entity_markup(&nsfw_node(), false).unwrap().into_string(); assert!(html.contains("NSFW")); assert!(html.contains("nsfw-gate")); + assert!(!html.contains("adult post")); assert!(!html.contains("adult body")); assert!(!html.contains("https://example.com/image.jpg")); assert!(!html.contains("https://example.com/out")); @@ -122,4 +132,45 @@ mod tests { assert!(html.contains("https://example.com/out")); assert!(!html.contains("nsfw-gate")); } + + #[test] + fn inherited_nsfw_child_never_renders_a_thumbnail() { + let parent = ItemId::from_url("https://reddit.com/r/nsfw").unwrap(); + let post = ItemId::from_url("https://reddit.com/r/nsfw/comments/abc/adult").unwrap(); + let mut tree = GlobalTree::new(); + tree.nodes.insert( + parent.clone(), + NodeState { + id: parent.clone(), + nsfw_classification: Some(true), + children: [post.clone()].into_iter().collect(), + ..Default::default() + }, + ); + tree.nodes.insert( + post.clone(), + NodeState { + id: post.clone(), + nsfw_classification: Some(false), + data: Some(EntityData { + title: "inherited adult post".into(), + author: None, + body_html: None, + over_18: false, + thumb_url: Some("https://example.com/should-not-load.jpg".into()), + image_url: None, + link_url: None, + }), + ..Default::default() + }, + ); + + let html = child_row_markup(&tree, &post, "/post") + .unwrap() + .into_string(); + assert!(html.contains("NSFW")); + assert!(html.contains("inherited adult post")); + assert!(!html.contains(" Result<(), String> { + pub async fn claim_pseudonym(&self, uuid: &str, pseudonym: &str) -> Result<(), String> { let ts = crate::html::now_ms(); self.journal .append(Event::PseudonymClaimed { @@ -315,7 +307,7 @@ mod tests { } #[tokio::test] - async fn rebuild_projection_drops_ephemeral_content() { + async fn rebuild_projection_drops_ephemeral_content_but_restores_nsfw_classification() { let tmp = tempfile::tempdir().unwrap(); let data_dir = tmp.path().to_string_lossy().into_owned(); let log = EventLog::new(format!("{data_dir}/events.jsonl")); @@ -327,6 +319,15 @@ mod tests { )) .await .unwrap(); + log.append(&event_record( + 2, + Event::NsfwClassified { + id: "https://reddit.com/r/rust".into(), + over_18: true, + }, + )) + .await + .unwrap(); let db = durable::Db::open(tmp.path().join("store")).unwrap(); let projection_store = ProjectionStore::from_db(&db).unwrap(); @@ -349,7 +350,12 @@ mod tests { 1, ) .unwrap(); - assert!(projection_store.load_node(&id).unwrap().unwrap().data.is_some()); + assert!(projection_store + .load_node(&id) + .unwrap() + .unwrap() + .data + .is_some()); drop(projection_store); drop(db); @@ -366,6 +372,8 @@ mod tests { let projection_store = ProjectionStore::from_db(&db).unwrap(); let node = projection_store.load_node(&id).unwrap().unwrap(); assert!(node.data.is_none()); + assert_eq!(node.nsfw_classification, Some(true)); + assert!(crate::nsfw::item_is_nsfw_in_store(&projection_store, &id)); } #[tokio::test] @@ -536,11 +544,21 @@ mod tests { .await; let err = state - .record_vote(&ItemId::root(), "alpha", "beta", 0, 0, &crate::auth::VoteActor::anon()) + .record_vote( + &ItemId::root(), + "alpha", + "beta", + 0, + 0, + &crate::auth::VoteActor::anon(), + ) .await .unwrap_err(); assert!(err.contains("positive preference")); - assert_eq!(state.projection_store.last_applied_event_count().unwrap(), 0); + assert_eq!( + state.projection_store.last_applied_event_count().unwrap(), + 0 + ); } #[tokio::test] diff --git a/server/src/storage_schema.rs b/server/src/storage_schema.rs index dac6f8e6080e3f5683c24dbb8861f5a981d456c4..68263eb00c3a49e71ea53112b431f2c7c3be7778 100644 --- a/server/src/storage_schema.rs +++ b/server/src/storage_schema.rs @@ -11,7 +11,7 @@ use durable::{Batch, Db, Durable, Leaf, List, Map}; use crate::{ path_types::ItemId, - reducer::{EntityData, NodeState, ScopeVotes, VoteData, UuidVoteKey, uuid_vote_key}, + reducer::{uuid_vote_key, EntityData, NodeState, ScopeVotes, UuidVoteKey, VoteData}, storage_dto::{ decode_entity_data, decode_vote, encode_entity_data, encode_vote, parse_stored_id, SessionDataV1, StoredEntityDataV1, StoredVoteV1, @@ -24,6 +24,7 @@ use crate::{ pub struct NodeSchema { pub present: Leaf, pub data: Leaf, + pub nsfw_classification: Leaf, pub children: Map>, pub uuid_votes: Map>, pub recent_votes: List>, @@ -96,7 +97,11 @@ pub fn pseudonym_owner(db: &Db, pseudonym: &str) -> durable::Result durable::Result> { +pub fn oauth_link_owner( + db: &Db, + provider: &str, + provider_id: &str, +) -> durable::Result> { Store::root() .oauth_links() .key(&oauth_link_key(provider, provider_id)) @@ -139,8 +144,14 @@ pub fn load_node_state(db: &Db, id: &ItemId) -> durable::Result durable::Result (SocketAddr, TempDir, String) { #[tokio::test] async fn post_ui_vote_compare_morphs_edge_history() { let (addr, _tmp, session_cookie) = start_test_server().await; - let parent = "reddit.com/r/rust"; - let a = "reddit.com/r/rust/comments/aaa/announcing_rust_199"; - let b = "reddit.com/r/rust/comments/bbb/what_are_you_working_on"; + // This test exercises vote morphing, not Reddit classification. Opaque + // entities remain safe by default; unclassified Reddit URLs fail closed. + let parent = "project"; + let a = "alpha"; + let b = "beta"; let rpc = serde_json::json!({ "action": "record_vote", @@ -225,6 +222,22 @@ async fn nsfw_items_hidden_until_opt_in_and_leave_returns() { state.ensure_node(&parent).await.unwrap(); state.ensure_node(&sfw).await.unwrap(); state.ensure_node(&nsfw).await.unwrap(); + state + .projection_store + .put_ephemeral_content( + &parent, + &EntityData { + title: "mixed".into(), + author: None, + body_html: None, + over_18: false, + thumb_url: None, + image_url: None, + link_url: None, + }, + 1, + ) + .unwrap(); state .projection_store .put_ephemeral_content( @@ -286,7 +299,9 @@ async fn nsfw_items_hidden_until_opt_in_and_leave_returns() { ); let nsfw_page = client - .get(format!("http://{addr}/~/https://reddit.com/r/mixed/comments/bbb")) + .get(format!( + "http://{addr}/~/https://reddit.com/r/mixed/comments/bbb" + )) .send() .await .unwrap() @@ -327,7 +342,10 @@ async fn nsfw_items_hidden_until_opt_in_and_leave_returns() { .text() .await .unwrap(); - assert!(opted.contains("adult post"), "opted-in should list NSFW: {opted}"); + assert!( + opted.contains("adult post"), + "opted-in should list NSFW: {opted}" + ); assert!( opted.contains("Exit NSFW"), "opted-in nav should offer leave: {opted}" diff --git a/test/reddit_import.clj b/test/reddit_import.clj index aa19126a11fe8d336115bfac8783cc31260a850e..9c90892f2c4aab6724d132793ccbdcf0dc5c53dd 100644 --- a/test/reddit_import.clj +++ b/test/reddit_import.clj @@ -82,11 +82,16 @@ (is (str/includes? (:out ranked-sse) "Idiomorph.morph")) (is (str/includes? (:out ranked-sse) "Announcing Rust 1.99")) (is (str/includes? (:out ranked-sse) "What are you working on this week?")) - ;; Ranked refresh must not append more structure events. - (is (= log-before-ranked (slurp (io/file log-path))))))) + ;; Ranked refresh persists refreshed safety classifications, but must not + ;; append duplicate structure events. + (let [log-after-ranked (slurp (io/file log-path))] + (is (= (count (re-seq #"\"type\":\"node_ensured\"" log-before-ranked)) + (count (re-seq #"\"type\":\"node_ensured\"" log-after-ranked)))) + (is (> (count (re-seq #"\"type\":\"nsfw_classified\"" log-after-ranked)) + (count (re-seq #"\"type\":\"nsfw_classified\"" log-before-ranked)))))))) (deftest reddit-fetch-via-mock-api - (testing "Fetch caches display content ephemerally; log records structure only" + (testing "Fetch caches display content ephemerally; log records structure and safety" (let [root (repo-root) fixtures (mock-reddit/fixtures-dir root) data-dir (.getAbsolutePath diff --git a/test/support/harness.clj b/test/support/harness.clj index 741024aab1d14269e225791189633287980ae2e4..71c018c5e8bc349e7424cc336907a8888f2a8e2f 100644 --- a/test/support/harness.clj +++ b/test/support/harness.clj @@ -26,13 +26,16 @@ (do (Thread/sleep 200) (recur)) false)))))) -(defn curl-fetch-children [base item] +(defn curl-fetch-entity [base item kind] (process/shell {:out :string :err :string} "curl" "-sfN" "--max-time" "20" "-X" "POST" (str base "/ui") "--data-urlencode" (str "__rpc__={\"action\":\"fetch_entity\",\"item\":\"" item - "\",\"kind\":\"children\"}"))) + "\",\"kind\":\"" kind "\"}"))) + +(defn curl-fetch-children [base item] + (curl-fetch-entity base item "children")) (defn app-env [data-dir app-port oauth-port reddit-port] @@ -103,6 +106,11 @@ "&mock_user=" (java.net.URLEncoder/encode mock-user "UTF-8"))) (defn seed-rust-children! [app-base] - (let [fetch (curl-fetch-children app-base "reddit.com/r/rust")] - (when-not (zero? (:exit fetch)) - (throw (ex-info "fetch rust children failed" {:err (:err fetch)}))))) + ;; Classify the subreddit before importing its posts. Unknown Reddit + ;; ancestors intentionally fail closed at listing and vote boundaries. + (let [self-fetch (curl-fetch-entity app-base "reddit.com/r/rust" "self") + children-fetch (curl-fetch-children app-base "reddit.com/r/rust")] + (when-not (zero? (:exit self-fetch)) + (throw (ex-info "fetch rust subreddit failed" {:err (:err self-fetch)}))) + (when-not (zero? (:exit children-fetch)) + (throw (ex-info "fetch rust children failed" {:err (:err children-fetch)})))))