Configurable ignore rules for unwanted content #31

Manually merged
marvin8 merged 6 commits from feat/issue-20-ignore-rules into main 2026-09-12 20:55:28 +00:00
Collaborator

Adds display-time ignore rules: one keyword per line, stored in the settings table. A reader group is hidden when any rule matches (case-insensitive substring) a member article title or one of the groups tags. Aggregation is untouched, so rules are reversible at any time.

  • services/ignore_rules.py: normalize_rules + matches_any_rule (pure, unit-tested)
  • Settings → Ignore Rules card (textarea, one rule per line) with POST /settings/ignore-rules
  • Reader index skips matching groups; g next-unread navigation walks past them too

Design decisions (filter point, rule model) per the issue discussion.

Closes #20

Adds display-time ignore rules: one keyword per line, stored in the settings table. A reader group is hidden when any rule matches (case-insensitive substring) a member article title or one of the groups tags. Aggregation is untouched, so rules are reversible at any time. - `services/ignore_rules.py`: `normalize_rules` + `matches_any_rule` (pure, unit-tested) - Settings → Ignore Rules card (textarea, one rule per line) with POST `/settings/ignore-rules` - Reader index skips matching groups; `g` next-unread navigation walks past them too Design decisions (filter point, rule model) per the issue discussion. Closes #20
Filter ignored aggregates from the reader and next-unread navigation
All checks were successful
/ gitleaks (pull_request) Successful in 25s
/ checks (pull_request) Successful in 1m31s
/ pr-review (pull_request) Successful in 2m24s
8aa0203106
forgejo-actions left a comment

WuMing

Found 8 issue(s). See inline comments below.

## WuMing Found **8** issue(s). See inline comments below.
@ -95,0 +104,4 @@
Cleaned ignore rules (empty when none are configured).
"""
raw_rules = settings_db.get_setting(db, user_id=1, key="ignore_rules") or "" # user_id=1 until #25

code [LOW]

_load_ignore_rules hardcodes user_id=1 while the reader routes already accept/use a user_id. This silently loads the wrong user's rules when multi-user support lands (#25). Pass user_id into _load_ignore_rules and use it here and at the call site in the aggregate route.

**code** [LOW] _load_ignore_rules hardcodes user_id=1 while the reader routes already accept/use a user_id. This silently loads the wrong user's rules when multi-user support lands (#25). Pass user_id into _load_ignore_rules and use it here and at the call site in the aggregate route. <!-- wuming:sha256:fa834351d01f70a1d8eae9f5aebabb540203678ae27448dc6ce77e11d7081aa0 -->

security [MEDIUM]

A01: Ignore rules are loaded for hard-coded user_id=1 rather than the currently authenticated user. This causes all users to share user 1's ignore rules and applies one user's settings to everyone. Use the authenticated user ID and validate ownership when reading settings.

**security** [MEDIUM] A01: Ignore rules are loaded for hard-coded user_id=1 rather than the currently authenticated user. This causes all users to share user 1's ignore rules and applies one user's settings to everyone. Use the authenticated user ID and validate ownership when reading settings. <!-- wuming:sha256:fa834351d01f70a1d8eae9f5aebabb540203678ae27448dc6ce77e11d7081aa0 -->
@ -95,0 +127,4 @@
return False
texts: list[str] = []
for article_id in agg_db.get_articles_in_aggregate(db, aggregate_id):

code [LOW]

The index filtering logic calls _aggregate_is_ignored for every aggregate, and this method then performs per-article and per-tag DB queries. With many aggregates this is an N+1 pattern and can make the reader index slow. Consider loading article titles/tags for all aggregates in bulk or caching per request.

**code** [LOW] The index filtering logic calls _aggregate_is_ignored for every aggregate, and this method then performs per-article and per-tag DB queries. With many aggregates this is an N+1 pattern and can make the reader index slow. Consider loading article titles/tags for all aggregates in bulk or caching per request. <!-- wuming:sha256:1ed2d2081b187beb92735f717c46d0f7224ae7d7eb18bcf66dc3a7fa3e4ba9bd -->
@ -195,3 +276,3 @@
summary_info = summarizer.extract_summary(db, article_ids)
next_unread_id = agg_db.get_next_aggregate_with_unread(db, user_id, aggregate_id)
next_unread_id = _find_next_unread_id(db, user_id, aggregate_id, _load_ignore_rules(db))

code [MEDIUM]

The aggregate detail route renders the summary and article content without checking _aggregate_is_ignored. A user can open /aggregate/ directly and bypass the ignore rules. Add an ignore check before rendering or redirect to the reader index if ignored groups must be hidden everywhere.

**code** [MEDIUM] The aggregate detail route renders the summary and article content without checking _aggregate_is_ignored. A user can open /aggregate/<id> directly and bypass the ignore rules. Add an ignore check before rendering or redirect to the reader index if ignored groups must be hidden everywhere. <!-- wuming:sha256:aab5356bcad0d795a811bc7e82183debfef1231497bcd0e0af1da079c86abc59 -->
@ -141,0 +150,4 @@
"""
db = get_db()
user_id = 1 # Default user for now (Phase 6 will add multi-user, see issue #25)

security [MEDIUM]

A01: The /settings/ignore-rules POST handler hard-codes user_id=1 instead of using the authenticated user and performs no authorization check. Any request can overwrite user 1's ignore rules, an insecure direct object reference. Use the current authenticated user ID from the session and enforce ownership.

**security** [MEDIUM] A01: The /settings/ignore-rules POST handler hard-codes user_id=1 instead of using the authenticated user and performs no authorization check. Any request can overwrite user 1's ignore rules, an insecure direct object reference. Use the current authenticated user ID from the session and enforce ownership. <!-- wuming:sha256:44dbe39784ddac25177bc8b27cd2fcde79ee1bfd700319ba06a9df31c81c5ece -->
@ -583,0 +588,4 @@
response = client.get("/settings/tags")
assert response.status_code == 200
assert b'name="ignore_rules"' in response.data

code [HIGH]

This test expects an ignore_rules textarea on /settings/tags, but the diff contains no settings.html changes adding that field. Without the template update the feature is not exposed and this test will fail. Ensure settings.html is modified in the PR.

**code** [HIGH] This test expects an ignore_rules textarea on /settings/tags, but the diff contains no settings.html changes adding that field. Without the template update the feature is not exposed and this test will fail. Ensure settings.html is modified in the PR. <!-- wuming:sha256:e03c21367cf0a00bd951713fc6e418ad8b3fb114dd39812a6780e91e21c22ca6 -->
@ -583,0 +695,4 @@
response = client.get(f"/aggregate/{visible_agg.id}")
assert f'/aggregate/{ignored_agg.id}"'.encode() not in response.data

code [LOW]

This test only asserts the ignored aggregate URL is absent from the visible aggregate page; it does not force ignored_agg to be the next unread candidate (timestamps are both datetime.now(), ordering is ambiguous). The skip logic may not be exercised. Use distinct published_at values or pre-seed read state/order so ignored_agg is definitely the next unread group.

**code** [LOW] This test only asserts the ignored aggregate URL is absent from the visible aggregate page; it does not force ignored_agg to be the next unread candidate (timestamps are both datetime.now(), ordering is ambiguous). The skip logic may not be exercised. Use distinct published_at values or pre-seed read state/order so ignored_agg is definitely the next unread group. <!-- wuming:sha256:581f2bdf785350663730de4701ad2e141ae2e9cc82465cdcc3b3d253dc0dd651 -->

tests [MEDIUM]

This test only asserts the ignored aggregate's URL is absent, which also passes if next-unread navigation renders no link at all (e.g. _find_next_unread_id always returns None). Add a positive assertion that the visible aggregate's /aggregate/ link (or a next-unread URL) is present, so the test actually verifies that navigation skipped the ignored group rather than yielding nothing.

**tests** [MEDIUM] This test only asserts the ignored aggregate's URL is absent, which also passes if next-unread navigation renders no link at all (e.g. `_find_next_unread_id` always returns None). Add a positive assertion that the visible aggregate's /aggregate/<id> link (or a next-unread URL) is present, so the test actually verifies that navigation skipped the ignored group rather than yielding nothing. <!-- wuming:sha256:581f2bdf785350663730de4701ad2e141ae2e9cc82465cdcc3b3d253dc0dd651 -->
🐛 Next-unread walk no longer revisits the current group
All checks were successful
/ gitleaks (pull_request) Successful in 14s
/ checks (pull_request) Successful in 1m27s
/ pr-review (pull_request) Successful in 2m24s
b8043b5ec4
Author
Collaborator

Fixed — next-unread skip test strengthened (and it caught a real bug)

tests/test_web.py · tests/test_web.py

This test only asserts the ignored aggregate URL is absent from the visible aggregate page…

The test now uses three groups with ids ordered so the naive unread query deterministically returns the ignored group first, and asserts both that the visible group is offered and the ignored one never is. Writing it exposed a genuine bug: after skipping a candidate, the walk re-queried with only the skipped id excluded, so navigation could point back at the group being read. The unread query now has a list variant (get_aggregates_with_unread) and the walk picks the first non-ignored candidate from it. Fixed in commit b8043b5.


🔴 Not actioned — hard-coded user_id = 1 / missing auth + CSRF

src/yunjin/web/routes/reader.py · src/yunjin/web/routes/reader.py · src/yunjin/web/routes/settings.py

A01: Ignore rules are loaded for hard-coded user_id=1 rather than the currently authenticated user…

Deliberate, project-level, and identical across every route in this codebase (each site carries a # user_id=1 until #25 comment). The auth/session/CSRF layer is tracked in issue #25 — the same findings were declined on PR #24 and consolidated there. Re-rolling it per-route would not change the outcome.


🔴 Not actioned — direct URL access to an ignored group is intentional

src/yunjin/web/routes/reader.py

The aggregate detail route renders the summary and article content without checking _aggregate_is_ignored…

Ignore rules are display-time curation, not access control: g navigation never lands on an ignored group, and opening its URL directly is explicit intent to read it. This is the documented design (see the issue's plan-of-attack comment: "direct URLs to ignored groups still work").


🔴 Not actioned — N+1 in rule matching matches the route's existing shape

src/yunjin/web/routes/reader.py

The index filtering logic calls _aggregate_is_ignored for every aggregate… this method then performs per-article and per-tag DB queries.

The same route already loads articles, media, tags, and read state per aggregate — the reader is N+1 by design at personal scale. Rule checks only add queries when rules are configured. Bulk loading is a route-wide refactor better done as perf work than smuggled into this feature.


🔴 Not actioned — settings.html is in this diff

tests/test_web.py

the diff contains no settings.html changes adding that field. Without the template update the feature is not exposed and this test will fail.

Factually incorrect: commit 826371a adds the Ignore Rules card to settings.html (+24 lines), and this PR's CI run (including the full pytest suite, which contains the test in question) is green.

### ✅ Fixed — next-unread skip test strengthened (and it caught a real bug) [`tests/test_web.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/31#issuecomment-2912) · [`tests/test_web.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/31#issuecomment-2915) > This test only asserts the ignored aggregate URL is absent from the visible aggregate page… The test now uses three groups with ids ordered so the naive unread query deterministically returns the ignored group first, and asserts both that the visible group **is** offered and the ignored one **never** is. Writing it exposed a genuine bug: after skipping a candidate, the walk re-queried with only the skipped id excluded, so navigation could point back at the group being read. The unread query now has a list variant (`get_aggregates_with_unread`) and the walk picks the first non-ignored candidate from it. Fixed in commit `b8043b5`. --- ### 🔴 Not actioned — hard-coded `user_id = 1` / missing auth + CSRF [`src/yunjin/web/routes/reader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/31#issuecomment-2907) · [`src/yunjin/web/routes/reader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/31#issuecomment-2914) · [`src/yunjin/web/routes/settings.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/31#issuecomment-2913) > A01: Ignore rules are loaded for hard-coded user_id=1 rather than the currently authenticated user… Deliberate, project-level, and identical across every route in this codebase (each site carries a `# user_id=1 until #25` comment). The auth/session/CSRF layer is tracked in issue #25 — the same findings were declined on PR #24 and consolidated there. Re-rolling it per-route would not change the outcome. --- ### 🔴 Not actioned — direct URL access to an ignored group is intentional [`src/yunjin/web/routes/reader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/31#issuecomment-2909) > The aggregate detail route renders the summary and article content without checking _aggregate_is_ignored… Ignore rules are display-time curation, not access control: `g` navigation never lands on an ignored group, and opening its URL directly is explicit intent to read it. This is the documented design (see the issue's plan-of-attack comment: "direct URLs to ignored groups still work"). --- ### 🔴 Not actioned — N+1 in rule matching matches the route's existing shape [`src/yunjin/web/routes/reader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/31#issuecomment-2910) > The index filtering logic calls _aggregate_is_ignored for every aggregate… this method then performs per-article and per-tag DB queries. The same route already loads articles, media, tags, and read state per aggregate — the reader is N+1 by design at personal scale. Rule checks only add queries when rules are configured. Bulk loading is a route-wide refactor better done as perf work than smuggled into this feature. --- ### 🔴 Not actioned — settings.html **is** in this diff [`tests/test_web.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/31#issuecomment-2911) > the diff contains no settings.html changes adding that field. Without the template update the feature is not exposed and this test will fail. Factually incorrect: commit `826371a` adds the Ignore Rules card to `settings.html` (+24 lines), and this PR's CI run (including the full pytest suite, which contains the test in question) is green.
forgejo-actions left a comment

WuMing

Found 4 issue(s). See inline comments below.

## WuMing Found **4** issue(s). See inline comments below.
@ -309,3 +334,1 @@
(user_id, exclude_aggregate_id),
)
row = cursor.fetchone()
aggregate_ids = get_aggregates_with_unread(conn, user_id, exclude_aggregate_id)

code [MEDIUM]

get_next_aggregate_with_unread now calls get_aggregates_with_unread, which fetches every unread aggregate ID, then takes the first element. This removes the previous LIMIT 1 and can materialize a huge list unnecessarily. Use a LIMIT 1 query or add a limit parameter to the helper.

**code** [MEDIUM] get_next_aggregate_with_unread now calls get_aggregates_with_unread, which fetches every unread aggregate ID, then takes the first element. This removes the previous LIMIT 1 and can materialize a huge list unnecessarily. Use a LIMIT 1 query or add a limit parameter to the helper. <!-- wuming:sha256:f6d176d3dbcdd469066fe8286a04a4fc01022362177c97d1bd295f80058c495c -->
@ -0,0 +50,4 @@
"""
folded_rules = [rule.casefold() for rule in rules]
return any(folded_rule in text.casefold() for folded_rule in folded_rules for text in texts)

code [LOW]

matches_any_rule does not filter out empty rules. An empty rule ("") is an empty substring and therefore matches every text, so matches_any_rule([""], ["anything"]) returns True. Since this is a public pure helper, guard against empty rules (or document/handle them explicitly) to prevent accidental hide-everything behavior.

**code** [LOW] matches_any_rule does not filter out empty rules. An empty rule ("") is an empty substring and therefore matches every text, so matches_any_rule([""], ["anything"]) returns True. Since this is a public pure helper, guard against empty rules (or document/handle them explicitly) to prevent accidental hide-everything behavior. <!-- wuming:sha256:6764033daee8be8859f721fad7396c5a6fdcf90b4cc015f02d11c6992fbe1885 -->
@ -95,0 +104,4 @@
Cleaned ignore rules (empty when none are configured).
"""
raw_rules = settings_db.get_setting(db, user_id=1, key="ignore_rules") or "" # user_id=1 until #25

security [MEDIUM]

A01: Ignore rules are loaded with hard-coded user_id=1 rather than the requesting user, so the reader applies user 1's rules regardless of identity. This breaks per-user access control / user isolation when authentication is present. Derive user_id from the authenticated session/request context.

**security** [MEDIUM] A01: Ignore rules are loaded with hard-coded user_id=1 rather than the requesting user, so the reader applies user 1's rules regardless of identity. This breaks per-user access control / user isolation when authentication is present. Derive user_id from the authenticated session/request context. <!-- wuming:sha256:dac9d1b2996f3b06f6cf9641b81dc39bb02bf7ff4fbc40712c1b8ffa54540c50 -->
@ -95,0 +128,4 @@
texts: list[str] = []
for article_id in agg_db.get_articles_in_aggregate(db, aggregate_id):
article = articles_db.get_article(db, article_id)

code [MEDIUM]

_aggregate_is_ignored runs get_article and get_tags_for_article for every article in every aggregate while rendering the index and for each next-unread candidate. With many aggregates/articles this causes a large N+1 query load and can make the reader slow. Batch-fetch article titles/tags per aggregate (or cache per request) instead.

**code** [MEDIUM] _aggregate_is_ignored runs get_article and get_tags_for_article for every article in every aggregate while rendering the index and for each next-unread candidate. With many aggregates/articles this causes a large N+1 query load and can make the reader slow. Batch-fetch article titles/tags per aggregate (or cache per request) instead. <!-- wuming:sha256:4e57f40377dbc682d2cf46ca88e406fa09c9befb9c0a19655bc374591b78c110 -->
🔥 Remove unread-aggregate helper superseded by the list query
All checks were successful
/ gitleaks (pull_request) Successful in 38s
/ pr-review (pull_request) Successful in 2m54s
/ checks (pull_request) Successful in 5m41s
aad5458e43
Author
Collaborator

Fixed — empty rules can no longer match everything

src/yunjin/services/ignore_rules.py

matches_any_rule does not filter out empty rules. An empty rule ("") is an empty substring and therefore matches every text…

Valid catch. matches_any_rule now skips empty rules (documented in its docstring), with a regression test. Fixed in commit 23e5770.


Resolved — the LIMIT-less single-result helper is gone

src/yunjin/db/aggregates.py

get_next_aggregate_with_unread now calls get_aggregates_with_unread, which fetches every unread aggregate ID, then takes the first element…

After the previous fix the single-result helper had no production callers left (the walk needs the full ordered list), so instead of duplicating the ordering SQL to restore LIMIT 1, the helper is removed outright and its test folded into the list variant's tests. Fixed in commit aad5458.


Duplicates of wave-1 findings (already answered)

src/yunjin/web/routes/reader.py · src/yunjin/web/routes/reader.py

Identical in substance to the N+1 and hard-coded user_id = 1 findings answered in the consolidated review reply above — both declined there with rationale (route-wide N+1 is the existing design; auth/CSRF is tracked project-level in issue #25). No further action.

### ✅ Fixed — empty rules can no longer match everything [`src/yunjin/services/ignore_rules.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/31#issuecomment-2923) > matches_any_rule does not filter out empty rules. An empty rule ("") is an empty substring and therefore matches every text… Valid catch. `matches_any_rule` now skips empty rules (documented in its docstring), with a regression test. Fixed in commit `23e5770`. --- ### ✅ Resolved — the LIMIT-less single-result helper is gone [`src/yunjin/db/aggregates.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/31#issuecomment-2922) > get_next_aggregate_with_unread now calls get_aggregates_with_unread, which fetches every unread aggregate ID, then takes the first element… After the previous fix the single-result helper had no production callers left (the walk needs the full ordered list), so instead of duplicating the ordering SQL to restore `LIMIT 1`, the helper is removed outright and its test folded into the list variant's tests. Fixed in commit `aad5458`. --- ### Duplicates of wave-1 findings (already answered) [`src/yunjin/web/routes/reader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/31#issuecomment-2921) · [`src/yunjin/web/routes/reader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/31#issuecomment-2924) Identical in substance to the N+1 and hard-coded `user_id = 1` findings answered in the consolidated review reply above — both declined there with rationale (route-wide N+1 is the existing design; auth/CSRF is tracked project-level in issue #25). No further action.
marvin8 approved these changes 2026-09-12 20:52:29 +00:00
marvin8 manually merged commit dd1b0e1fe4 into main 2026-09-12 20:55:28 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
marvin8/yunjin!31
No description provided.