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