Block lists: words that hide items and keep them from downloading (#47)

Each person has a list for every feed they read and one per feed. An item whose title or text
holds one of the words, matched as whole words so "ai" does not hide everything that "said"
anything, is hidden from them and, since the scanner now keeps each subscriber's filters
separate, is fetched only if someone else still wants it.

Whole-word matching is not something LIKE can do on both SQLite and Postgres, so the matches are
worked out in Rust into a `hidden` table whenever a list changes, someone subscribes, or a scan
brings in new items, and the queries only look that table up. Both new tables are tables rather
than columns because create_missing adds tables but never columns. Hidden counts as read for
the reaper and for "others still want this file", since whoever it is hidden from is as done
with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 13:13:03 +00:00
parent 868dd673f3
commit bdda9b3d2e
8 changed files with 383 additions and 55 deletions

View File

@@ -332,29 +332,40 @@ async fn me(
let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
"blocked": blocked,
}))
}
#[derive(Deserialize)]
struct MePatch {
theme: String,
mode: String,
theme: Option<String>,
mode: Option<String>,
/// Words that hide an item in every feed you read.
blocked: Option<Vec<String>>,
}
/// Saves the theme to the account, so it follows the person rather than the browser.
/// Saves the theme to the account, so it follows the person rather than the browser, and the
/// block list for every feed.
async fn patch_me(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<MePatch>,
) -> Result<StatusCode, ApiError> {
// The page's script knows the themes; this only makes sure what is kept is safe to write
// into the page's <html> tag, which is where index() puts it.
if !theme_ok(&body.theme, &body.mode) {
return Err(ApiError::bad_request("not a theme"));
if body.theme.is_some() || body.mode.is_some() {
let (theme, mode) = (body.theme.unwrap_or_default(), body.mode.unwrap_or_default());
// The page's script knows the themes; this only makes sure what is kept is safe to write
// into the page's <html> tag, which is where index() puts it.
if !theme_ok(&theme, &mode) {
return Err(ApiError::bad_request("not a theme"));
}
state.ctx.db.set_theme(user.id, &theme, &mode).await?;
}
if let Some(words) = body.blocked {
state.ctx.db.set_blocklist(user.id, "", &clean_words(words)?).await?;
}
state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -639,6 +650,8 @@ struct FeedRow {
category: Option<String>,
feed_category: Option<String>,
keywords: Vec<String>,
/// Your words that hide an item of this feed, beside your list for every feed.
blocked: Vec<String>,
allow_explicit: bool,
auto_download: bool,
max_new_per_check: Option<usize>,
@@ -715,6 +728,7 @@ async fn feeds(
.clone()
.or_else(|| up.and_then(|u| u.keywords.clone()))
.unwrap_or_else(|| feed.keywords.clone()),
blocked: state.ctx.db.blocklist(user.id, id).await?,
allow_explicit: mine
.allow_explicit
.or(up.and_then(|u| u.allow_explicit))
@@ -1272,6 +1286,7 @@ struct FeedPatch {
#[serde(default, deserialize_with = "double_option")]
category: Option<Option<String>>,
keywords: Option<Vec<String>>,
blocked: Option<Vec<String>>,
allow_explicit: Option<bool>,
auto_download: Option<bool>,
#[serde(default, deserialize_with = "double_option")]
@@ -1279,6 +1294,21 @@ struct FeedPatch {
pinned: Option<bool>,
}
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
/// looked for in every item of every feed the list covers.
fn clean_words(words: Vec<String>) -> Result<Vec<String>, ApiError> {
let mut out: Vec<String> = Vec::new();
for w in words.iter().map(|w| w.trim()).filter(|w| !w.is_empty()) {
if !out.iter().any(|o| o.eq_ignore_ascii_case(w)) {
out.push(w.to_owned());
}
}
if out.len() > 500 || out.iter().any(|w| w.len() > 200) {
return Err(ApiError::bad_request("a block list holds up to 500 words of up to 200 characters"));
}
Ok(out)
}
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
where
T: Deserialize<'de>,
@@ -1327,6 +1357,9 @@ async fn patch_feed(
if touched {
state.ctx.db.set_subscription(user.id, &mine).await?;
}
if let Some(v) = body.blocked.clone() {
state.ctx.db.set_blocklist(user.id, &id, &clean_words(v)?).await?;
}
}
// The rest describes the feed itself -- where its files land, its address, when it is