Reader: mark groups read and toggle hiding of read items #24

Manually merged
marvin8 merged 3 commits from feat/issue-18-read-unread into main 2026-09-11 23:31:38 +00:00
Collaborator

Implements issue #18 (from the Todo.md conversion) with the design decisions recorded on the issue:

  • Mark group as read: new POST /aggregate/<id>/read marks every member article read via the existing per-user read-status layer, then redirects back to the aggregate page (POST-redirect-GET — works from a plain form, no JavaScript). The group read state stays computed (all members read), so a group with a newly arrived article shows unread again automatically. The existing per-article read/unread endpoints remain JSON — they are the API #5 keyboard shortcuts will call.
  • Hide-read toggle: ?hide_read=1 query param with server-rendered Show/Hide-read links (default off). The reader index hides fully-read aggregates; the aggregate view hides read articles and shows an "All articles read" empty state. Toggle state carries through card links. Persistence as a default preference is deferred to #8 settings store.

Controls styled with existing button classes; no new CSS variables needed. All behaviour covered by 5 new route tests (31 web tests total, all nox sessions green).

Closes #18

Implements issue #18 (from the Todo.md conversion) with the design decisions recorded on the issue: - **Mark group as read**: new `POST /aggregate/<id>/read` marks every member article read via the existing per-user read-status layer, then redirects back to the aggregate page (POST-redirect-GET — works from a plain form, no JavaScript). The group read state stays *computed* (all members read), so a group with a newly arrived article shows unread again automatically. The existing per-article read/unread endpoints remain JSON — they are the API #5 keyboard shortcuts will call. - **Hide-read toggle**: `?hide_read=1` query param with server-rendered Show/Hide-read links (default off). The reader index hides fully-read aggregates; the aggregate view hides read articles and shows an "All articles read" empty state. Toggle state carries through card links. Persistence as a default preference is deferred to #8 settings store. Controls styled with existing button classes; no new CSS variables needed. All behaviour covered by 5 new route tests (31 web tests total, all nox sessions green). Closes #18
Add mark-group-read and hide-read toggle to reader
All checks were successful
/ gitleaks (pull_request) Successful in 13s
/ checks (pull_request) Successful in 1m27s
/ pr-review (pull_request) Successful in 3m12s
3bfcb47a86
forgejo-actions left a comment

WuMing

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

## WuMing Found **3** issue(s). See inline comments below.
@ -122,6 +129,7 @@ def view_aggregate(aggregate_id: int) -> str | tuple[str, int]:
"""
db = get_db()
user_id = 1

security [MEDIUM]

A01: The new state-changing endpoint hard-codes user_id=1 instead of deriving the current authenticated user from the session. Any caller can alter the default user's read state, and the aggregate is not checked against the caller's authorization. Use the authenticated user ID and verify access to the aggregate.

**security** [MEDIUM] A01: The new state-changing endpoint hard-codes user_id=1 instead of deriving the current authenticated user from the session. Any caller can alter the default user's read state, and the aggregate is not checked against the caller's authorization. Use the authenticated user ID and verify access to the aggregate. <!-- wuming:sha256:b3940ae44ddeb7b757d6c53105762255e58938a385b0608abf8df116a22e9bdb -->
@ -198,6 +210,33 @@ def view_article(article_id: int) -> str | tuple[str, int]:
)
@bp.route("/aggregate/<int:aggregate_id>/read", methods=["POST"])

security [LOW]

A01: This state-changing POST endpoint lacks CSRF protection. An attacker could trigger a cross-site request to mark an aggregate as read. Add CSRF token validation, e.g. Flask-WTF CSRFProtect, to all state-changing routes.

**security** [LOW] A01: This state-changing POST endpoint lacks CSRF protection. An attacker could trigger a cross-site request to mark an aggregate as read. Add CSRF token validation, e.g. Flask-WTF CSRFProtect, to all state-changing routes. <!-- wuming:sha256:c4477ab2f9048796da273092c3fb39616dcbdbef4042db2a94da0ef999a75491 -->
@ -201,0 +234,4 @@
for article_id in agg_db.get_articles_in_aggregate(db, aggregate_id):
users.mark_article_read(db, user_id, article_id)
return redirect(url_for("reader.view_aggregate", aggregate_id=aggregate_id))

code [MEDIUM]

The redirect after marking a group as read drops any hide_read query parameter. If the user submits the form from /aggregate/?hide_read=1, they are redirected to the default aggregate view and all the just-hidden read articles become visible again. Preserve the current filter, e.g. by reading hide_read from a hidden form field/request.args and passing hide_read=1 to url_for when appropriate; add a test for this path.

**code** [MEDIUM] The redirect after marking a group as read drops any hide_read query parameter. If the user submits the form from /aggregate/<id>?hide_read=1, they are redirected to the default aggregate view and all the just-hidden read articles become visible again. Preserve the current filter, e.g. by reading hide_read from a hidden form field/request.args and passing hide_read=1 to url_for when appropriate; add a test for this path. <!-- wuming:sha256:4a721e07e82bd1aa3bb7e2c297bce3f9eb1c3900425c0cea3a70ba40d6763159 -->
Author
Collaborator

src/yunjin/web/routes/reader.py

The redirect after marking a group as read drops any hide_read query parameter…

Fixed — the form action now carries the current filter state, the endpoint reads it back and redirects with hide_read=1 preserved. Regression test asserts the redirect Location keeps hide_read=1.

[`src/yunjin/web/routes/reader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/24#issuecomment-2637) > The redirect after marking a group as read drops any hide_read query parameter… ✅ Fixed — the form action now carries the current filter state, the endpoint reads it back and redirects with `hide_read=1` preserved. Regression test asserts the redirect Location keeps `hide_read=1`.
🐛 Preserve hide_read filter when marking aggregate as read
All checks were successful
/ gitleaks (pull_request) Successful in 13s
/ checks (pull_request) Successful in 1m28s
/ pr-review (pull_request) Successful in 3m46s
33b78731eb
Author
Collaborator

src/yunjin/web/routes/reader.py

A01: The new state-changing endpoint hard-codes user_id=1 instead of deriving the current authenticated user…

🔴 Not actioned in this PR — the hard-coded user_id = 1 (annotated # Phase 6 will add multi-user) is the established project-wide pattern shared by every reader route, including the pre-existing per-article read/unread endpoints. There is no auth/session layer to derive a user from yet; adding authentication is the planned Phase 6 work and would be a project-level change, not something to introduce piecemeal in one endpoint. This endpoint deliberately matches its siblings.

[`src/yunjin/web/routes/reader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/24#issuecomment-2638) > A01: The new state-changing endpoint hard-codes user_id=1 instead of deriving the current authenticated user… 🔴 Not actioned in this PR — the hard-coded `user_id = 1` (annotated `# Phase 6 will add multi-user`) is the established project-wide pattern shared by every reader route, including the pre-existing per-article read/unread endpoints. There is no auth/session layer to derive a user from yet; adding authentication is the planned Phase 6 work and would be a project-level change, not something to introduce piecemeal in one endpoint. This endpoint deliberately matches its siblings.
Author
Collaborator

src/yunjin/web/routes/reader.py

A01: This state-changing POST endpoint lacks CSRF protection…

🔴 Not actioned in this PR — CSRF hardening would need to cover every state-changing route (the pre-existing per-article read/unread and feed-management POSTs have the same exposure), and with no auth/cookies in the app the cross-site forgery vector reduces to network reachability of a single-user, LAN-oriented tool. Flagged to the maintainer as a candidate project-level hardening issue to bundle with the Phase 6 multi-user work rather than patching one endpoint.

[`src/yunjin/web/routes/reader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/24#issuecomment-2639) > A01: This state-changing POST endpoint lacks CSRF protection… 🔴 Not actioned in this PR — CSRF hardening would need to cover every state-changing route (the pre-existing per-article read/unread and feed-management POSTs have the same exposure), and with no auth/cookies in the app the cross-site forgery vector reduces to network reachability of a single-user, LAN-oriented tool. Flagged to the maintainer as a candidate project-level hardening issue to bundle with the Phase 6 multi-user work rather than patching one endpoint.
forgejo-actions left a comment

WuMing

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

## WuMing Found **3** issue(s). See inline comments below.
@ -199,2 +211,4 @@
@bp.route("/aggregate/<int:aggregate_id>/read", methods=["POST"])
def mark_aggregate_read(aggregate_id: int) -> tuple[dict[str, str], int] | Any:

code [HIGH]

The annotation uses Any, but the shown import block only imports TypedDict from typing. If Any is not imported elsewhere (and from __future__ import annotations is not active), this will raise NameError at import time. Also tuple[dict[str, str], int] | Any collapses to Any, disabling useful type checking; prefer a Flask response return type and import it.

**code** [HIGH] The annotation uses `Any`, but the shown import block only imports `TypedDict` from typing. If `Any` is not imported elsewhere (and `from __future__ import annotations` is not active), this will raise NameError at import time. Also `tuple[dict[str, str], int] | Any` collapses to `Any`, disabling useful type checking; prefer a Flask response return type and import it. <!-- wuming:sha256:726259949f68a6879c0ace7f2bc100de291ad20a3959b7d32a332c3847c4fcc4 -->
@ -201,0 +225,4 @@
"""
db = get_db()
user_id = 1

security [MEDIUM]

A01: The new POST route always acts as hard-coded user_id=1 and does not derive the user from an authenticated session or verify that the requester is authorised to modify the specified aggregate. Any client can mark any aggregate as read. Use the current authenticated user and check aggregate access.

**security** [MEDIUM] A01: The new POST route always acts as hard-coded user_id=1 and does not derive the user from an authenticated session or verify that the requester is authorised to modify the specified aggregate. Any client can mark any aggregate as read. Use the current authenticated user and check aggregate access. <!-- wuming:sha256:85e94e81b42f377aa98c302c5dd51e777a869caaa41c9a48201d51958c18126d -->
@ -204,0 +257,4 @@
users.mark_article_read(db_conn, user_id=1, article_id=sample_articles[0].id)
shown_response = client.get(f"/aggregate/{sample_aggregate.id}")
hidden_response = client.get(f"/aggregate/{sample_aggregate.id}?hide_read=1")

code [LOW]

This verifies the aggregate endpoint directly with ?hide_read=1, but there is no test that the reader index card links actually carry hide_read through to the aggregate URL. Add a test that loads /?hide_read=1, extracts/follows the aggregate link, and asserts the aggregate view hides read articles.

**code** [LOW] This verifies the aggregate endpoint directly with `?hide_read=1`, but there is no test that the reader index card links actually carry `hide_read` through to the aggregate URL. Add a test that loads `/?hide_read=1`, extracts/follows the aggregate link, and asserts the aggregate view hides read articles. <!-- wuming:sha256:11228b1dff53fb9cfa6368d6c4853de76c954204403f7d17940c417d88d99fdb -->
Author
Collaborator

src/yunjin/web/routes/reader.py

The annotation uses Any… If Any is not imported elsewhere… this will raise NameError at import time.

Partially addressed — the NameError premise doesn't hold (Any is imported at the top of the module and used by teardown_request), but the substantive point was right: tuple[...] | Any collapses to Any. The return annotation is now flask.typing.ResponseReturnValue, the purpose-built Flask type for route returns (redirect + (json, status) tuple both satisfy it), verified with ty.

[`src/yunjin/web/routes/reader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/24#issuecomment-2645) > The annotation uses Any… If Any is not imported elsewhere… this will raise NameError at import time. ✅ Partially addressed — the NameError premise doesn't hold (`Any` is imported at the top of the module and used by `teardown_request`), but the substantive point was right: `tuple[...] | Any` collapses to `Any`. The return annotation is now `flask.typing.ResponseReturnValue`, the purpose-built Flask type for route returns (redirect + `(json, status)` tuple both satisfy it), verified with ty.
Type mark-aggregate-read return, lock hide_read card links
All checks were successful
/ gitleaks (pull_request) Successful in 18s
/ checks (pull_request) Successful in 1m23s
/ pr-review (pull_request) Successful in 3m46s
217f412049
Author
Collaborator

src/yunjin/web/routes/reader.py

A01: The new POST route always acts as hard-coded user_id=1…

⏸️ Standing decision — same finding as the 🔴 reply two comments above: user_id = 1 (annotated # Phase 6 will add multi-user) is the project-wide pattern in every reader route; there is no auth layer to derive a user from. Flagged to the maintainer as Phase 6 work, not piecemeal per-endpoint.

[`src/yunjin/web/routes/reader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/24#issuecomment-2647) > A01: The new POST route always acts as hard-coded user_id=1… ⏸️ Standing decision — same finding as the 🔴 reply two comments above: `user_id = 1` (annotated `# Phase 6 will add multi-user`) is the project-wide pattern in every reader route; there is no auth layer to derive a user from. Flagged to the maintainer as Phase 6 work, not piecemeal per-endpoint.
Author
Collaborator

tests/test_web.py

…no test that the reader index card links actually carry hide_read through to the aggregate URL…

Fixed — test_index_card_links_carry_hide_read asserts the card href on /?hide_read=1 is /aggregate/<id>?hide_read=1, locking the filter carry-through.

[`tests/test_web.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/24#issuecomment-2646) > …no test that the reader index card links actually carry hide_read through to the aggregate URL… ✅ Fixed — `test_index_card_links_carry_hide_read` asserts the card href on `/?hide_read=1` is `/aggregate/<id>?hide_read=1`, locking the filter carry-through.
forgejo-actions left a comment

WuMing

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

## WuMing Found **1** issue(s). See inline comments below.
@ -201,0 +226,4 @@
"""
db = get_db()
user_id = 1

security [MEDIUM]

A01: The new /aggregate/<aggregate_id>/read POST endpoint has no authentication or authorization check and hard-codes the acting user as user_id=1. Any unauthenticated client can mark every article in any aggregate as read for the default user. Derive the user from an authenticated session and add CSRF protection for this state-changing route.

**security** [MEDIUM] A01: The new /aggregate/<aggregate_id>/read POST endpoint has no authentication or authorization check and hard-codes the acting user as user_id=1. Any unauthenticated client can mark every article in any aggregate as read for the default user. Derive the user from an authenticated session and add CSRF protection for this state-changing route. <!-- wuming:sha256:e116be1d3050348ac4cbba90209d09ce784681bfd7acadde60b591d3ebb491bc -->
Author
Collaborator

src/yunjin/web/routes/reader.py

A01: The new /aggregate/<aggregate_id>/read POST endpoint has no authentication or authorization check…

⏸️ Standing decisions — this re-raises the two findings declined above with reasons: hard-coded user_id = 1 is the project-wide Phase 6 pattern present in every reader route, and CSRF hardening is a project-level change spanning all state-changing routes, flagged to the maintainer to bundle with Phase 6. No further action planned in this PR.

[`src/yunjin/web/routes/reader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/24#issuecomment-2653) > A01: The new /aggregate/<aggregate_id>/read POST endpoint has no authentication or authorization check… ⏸️ Standing decisions — this re-raises the two findings declined above with reasons: hard-coded `user_id = 1` is the project-wide Phase 6 pattern present in every reader route, and CSRF hardening is a project-level change spanning all state-changing routes, flagged to the maintainer to bundle with Phase 6. No further action planned in this PR.
marvin8 manually merged commit f0f30cc482 into main 2026-09-11 23:31:38 +00:00
Author
Collaborator

Filed the project-level issue for the two declined findings: the auth/multi-user/CSRF work now lives in a dedicated hardening issue and will be picked up as Phase 6. This PR stays as-is.

Filed the project-level issue for the two declined findings: the auth/multi-user/CSRF work now lives in a dedicated hardening issue and will be picked up as Phase 6. This PR stays as-is.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
2 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!24
No description provided.