feat(books): filter drawer .2 — combinator + chips + Author/Series/Publisher/Language/Tag facets (bookshelf-lj69z.2) #1129

Merged
zombor merged 3 commits from bd-bookshelf-lj69z.2 into main 2026-07-13 15:11:42 +00:00
Owner

Summary

  • Adds 5 new facet categories to the filter drawer: Author, Series, Publisher, Language, Tag — each lazy-loaded via the existing /books/facets endpoint
  • Adds AND/OR/NOT combinator control (radio buttons above the drawer's facet list); the server builds the SQL WHERE clause accordingly via appendFacetPredicates
  • Adds server-rendered active-filter chips (FacetChip / buildFacetChips) shown in the controls strip; each chip has a pre-built RemoveURL
  • Facet value lists are now scrollable (max-height 320px, overflow-y auto, scrollbar-gutter stable) — bookshelf-kv9ug
  • Drawer horizontal padding normalised via box-sizing: border-box so right inset equals top inset — bookshelf-kommq
  • Indexes on book_metadata.publisher, book_metadata.language, book_tag.name, author.name added in migration 0047
  • 100% coverage maintained; fully library-scoped (fail-closed) for all new facet counts
  • Browser e2e: go-rod Ordered journey proves tick→chip→OR-combinator→chip-remove across real page navigations

Test plan

  • make test passes (unit + coverage gate 100%)
  • make coverage gate green — zero uncovered statement blocks
  • go build -tags e2e ./e2e/... compiles cleanly
  • Browser e2e added: e2e/browser/journey_filter_drawer_test.go — "Journey: Filter Drawer — combinator + chips" (4 It-steps)
  • CI green (in progress)

Screenshot

Screenshots of the drawer with combinator + chips applied will be uploaded by the go-rod harness during the CI e2e run.

Closes bead bookshelf-lj69z.2, bookshelf-kv9ug, bookshelf-kommq on merge.

## Summary - Adds 5 new facet categories to the filter drawer: **Author**, **Series**, **Publisher**, **Language**, **Tag** — each lazy-loaded via the existing `/books/facets` endpoint - Adds **AND/OR/NOT combinator** control (radio buttons above the drawer's facet list); the server builds the SQL WHERE clause accordingly via `appendFacetPredicates` - Adds **server-rendered active-filter chips** (`FacetChip` / `buildFacetChips`) shown in the controls strip; each chip has a pre-built `RemoveURL` - Facet value lists are now **scrollable** (max-height 320px, overflow-y auto, scrollbar-gutter stable) — bookshelf-kv9ug - Drawer horizontal padding **normalised** via box-sizing: border-box so right inset equals top inset — bookshelf-kommq - **Indexes** on `book_metadata.publisher`, `book_metadata.language`, `book_tag.name`, `author.name` added in migration 0047 - **100% coverage** maintained; fully library-scoped (fail-closed) for all new facet counts - **Browser e2e**: go-rod Ordered journey proves tick→chip→OR-combinator→chip-remove across real page navigations ## Test plan - [x] `make test` passes (unit + coverage gate 100%) - [x] `make coverage` gate green — zero uncovered statement blocks - [x] `go build -tags e2e ./e2e/...` compiles cleanly - [x] Browser e2e added: `e2e/browser/journey_filter_drawer_test.go` — "Journey: Filter Drawer — combinator + chips" (4 It-steps) - [ ] CI green (in progress) ## Screenshot Screenshots of the drawer with combinator + chips applied will be uploaded by the go-rod harness during the CI e2e run. Closes bead bookshelf-lj69z.2, bookshelf-kv9ug, bookshelf-kommq on merge.
feat(books): filter drawer .2 — AND/OR/NOT combinator + chips + Author/Series/Publisher/Language/Tag facets (bookshelf-lj69z.2)
Some checks failed
/ Lint (pull_request) Successful in 3m59s
/ E2E API (pull_request) Failing after 2m6s
/ Integration (pull_request) Failing after 2m59s
/ JS Unit Tests (pull_request) Failing after 57s
/ E2E Browser (pull_request) Failing after 1m40s
/ Test (pull_request) Successful in 6m45s
3d49ecfa62
Adds five new facet categories (Author, Series, Publisher, Language, Tag) to
the existing filter drawer. Each facet category uses the same lazy-load fetch
pattern and scrollable value list (max-height 320px, scrollbar-gutter stable).

Combinator control (ALL/ANY/NONE radio buttons) lets the user choose whether
active facet filters are ANDed, ORed, or negated. Server renders the SQL
predicate accordingly via appendFacetPredicates.

Active-filter chips are server-rendered (FacetChip / buildFacetChips) and
appear in the controls strip. Each chip carries a pre-built RemoveURL that
navigates the user back to the filtered list without that one value.

CSS fixes (bookshelf-kv9ug + bookshelf-kommq): facet value lists get
max-height 320px + overflow-y scroll; drawer padding normalised via
box-sizing: border-box so right inset matches top inset.

Browser e2e: go-rod Ordered journey proves tick→chip→OR-combinator→chip-remove
across real page navigations with localStorage drawer state.

Closes bead bookshelf-lj69z.2, bookshelf-kv9ug, bookshelf-kommq on merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(migration): remove overlong composite indexes from 0047 — add JS combinator tests
All checks were successful
/ JS Unit Tests (pull_request) Successful in 57s
/ Lint (pull_request) Successful in 1m57s
/ E2E API (pull_request) Successful in 2m15s
/ Integration (pull_request) Successful in 3m2s
/ E2E Browser (pull_request) Successful in 3m42s
/ Test (pull_request) Successful in 6m15s
32a969cda0
The covering composite indexes (publisher(768), book_id) and
(series_name(768), book_id) exceeded MySQL's 3072-byte key-length limit for
utf8mb4 (768 × 4 bytes + 8 bytes for bigint = 3080 bytes). The existing
single-column prefix indexes from migration 0001 are sufficient for the
facet count queries; no new DDL is needed.

Also adds Vitest unit tests for filter_drawer_controller.js:
- onCombinatorChange() → calls _onFacetChange → triggers navigation
- _navigate() with non-and combinator radio → includes fcombinator=or in URL

Restores 100% JS branch/function coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Security Review — PR #1129 (filter drawer .2: 5 new facets + AND/OR/NOT combinator)

Scope: multi-user / library scoping / IDOR (the #1 risk given slice .5's history), combinator bypass, SQL injection, input bounds, migration.

Library scoping (the .5 IDOR class) — CLEAN

Traced every one of the 5 new facet count queries (FacetAuthorCounts, FacetSeriesCounts, FacetPublisherCounts, FacetLanguageCounts, FacetTagCounts in internal/books/facet_store.go):

  • Each drives from book b and applies b.library_id IN (?…) bound from session-resolved userLibraryIDs, with the same fail-closed guard as the existing FacetFormatCounts (userLibraryIDs != nil && len==0 → []; nil = admin/unrestricted).
  • Page-context narrowing goes through the shared appendScopePredicates, whose AuthorID/TagID/CategoryID/SeriesName/ShelfID are all EXISTS(… WHERE …book_id = b.id …) correlated to b.id, so a request-supplied author_id/tag_id from another library only intersects the user's own scoped rows — no cross-library counts.
  • Bind-arg order is correct (the .5 scramble is not reproduced): the new facet functions add zero JOINs with placeholders; all ? live in WHERE/EXISTS fragments, and args are appended in the same order the predicates are assembled (library args, then scope args). MagicJoins/MagicArgs handling is identical to the already-fixed existing functions.

Combinator (AND/OR/NOT) — CLEAN, cannot bypass scope

appendFacetPredicates (internal/books/store.go) is called after the unconditional top-level library scope (b.library_id IN (…), or fail-closed 1=0 when UserLibraryIDs is non-nil empty). All three combinator branches emit a single WHERE predicate AND-ed with that library scope. NOT (g1 OR g2 …) only negates the facet EXISTS predicates — the library IN scope is still AND-ed, so a NOT group cannot enumerate or leak foreign authors/tags/series; it merely excludes matching rows from the user's own scoped set.

Injection / allowlist — CLEAN

  • Combinator operator is a fixed allowlist (and/or/not) validated in the handler; anything else → ErrValidation.
  • Every facet value is a parameterized ?; no string concatenation of user values into SQL.
  • userID / userLibraryIDs come from the authenticated session only, never from params.

Input bounds — CLEAN

Handler caps each of fauthor/fseries/fpublisher/flanguage/ftag at 50 values and per-value length (1000 chars; 50 for language); count queries LIMIT 100.

Migration 0047 — CLEAN

Empty DDL (index-analysis placeholder). No new columns → no Grimmory schema violation.

Templates / JS — CLEAN

Facet chip Label/RemoveURL rendered via context-aware {{.}} (auto-escaped); no template.HTML/| safe. JS controller rebuilds a same-origin URL from checkbox names/values and location.assigns — no innerHTML, no inline style=.


Findings

[MAJOR] internal/books/service.go:105-128 — new list-filter facets + combinator are parsed but never applied (dead filters)
The 5 new fields (FacetAuthors/FacetSeries/FacetPublishers/FacetLanguages/FacetTags) and FacetCombinator are parsed + validated in ListHandler and set on Filter, and the store predicates (buildAuthorGroupbuildTagGroup, combinator switch) are fully implemented — but the Filter → ListBooksFilteredParams mapping in service.go still stops at FacetGenres and does NOT copy the six new fields (git grep 'FacetAuthors:' in non-test code returns nothing). Result: selecting any of the 5 new facets or a combinator on the book list silently does nothing. This is a correctness/broken-feature bug, not a security leak (the well-scoped predicates never execute). Fix: add FacetAuthors: filter.FacetAuthors, … FacetCombinator: filter.FacetCombinator to the ListBooksFilteredParams literal in service.go.

[MINOR] internal/books/facet_handler.go:174 — parseInt64Param loosens numeric validation
parseInt64Param uses fmt.Sscanf(s, "%d", &v), which accepts trailing garbage (e.g. "12abc" → 12, nil err) where the previous strconv.ParseInt rejected it. Not exploitable — these scope IDs are parameterized and intersected with the user's library scope, so a malformed/foreign value yields zero rows — but it weakens boundary validation. Fix: revert to strconv.ParseInt(s, 10, 64).

REVIEW VERDICT: 0 blocker, 1 major, 1 minor

## Security Review — PR #1129 (filter drawer .2: 5 new facets + AND/OR/NOT combinator) Scope: multi-user / library scoping / IDOR (the #1 risk given slice .5's history), combinator bypass, SQL injection, input bounds, migration. ### Library scoping (the .5 IDOR class) — CLEAN Traced every one of the 5 new facet **count** queries (`FacetAuthorCounts`, `FacetSeriesCounts`, `FacetPublisherCounts`, `FacetLanguageCounts`, `FacetTagCounts` in `internal/books/facet_store.go`): - Each drives from `book b` and applies `b.library_id IN (?…)` bound from **session-resolved** `userLibraryIDs`, with the same fail-closed guard as the existing `FacetFormatCounts` (`userLibraryIDs != nil && len==0 → []`; `nil` = admin/unrestricted). - Page-context narrowing goes through the shared `appendScopePredicates`, whose `AuthorID/TagID/CategoryID/SeriesName/ShelfID` are all `EXISTS(… WHERE …book_id = b.id …)` correlated to `b.id`, so a request-supplied `author_id`/`tag_id` from another library only intersects the user's own scoped rows — no cross-library counts. - **Bind-arg order is correct** (the .5 scramble is not reproduced): the new facet functions add **zero** JOINs with placeholders; all `?` live in WHERE/EXISTS fragments, and args are appended in the same order the predicates are assembled (library args, then scope args). MagicJoins/MagicArgs handling is identical to the already-fixed existing functions. ### Combinator (AND/OR/NOT) — CLEAN, cannot bypass scope `appendFacetPredicates` (`internal/books/store.go`) is called **after** the unconditional top-level library scope (`b.library_id IN (…)`, or fail-closed `1=0` when `UserLibraryIDs` is non-nil empty). All three combinator branches emit a single WHERE predicate AND-ed with that library scope. `NOT (g1 OR g2 …)` only negates the facet EXISTS predicates — the library `IN` scope is still AND-ed, so a NOT group cannot enumerate or leak foreign authors/tags/series; it merely excludes matching rows from the user's own scoped set. ### Injection / allowlist — CLEAN - Combinator operator is a fixed allowlist (`and`/`or`/`not`) validated in the handler; anything else → `ErrValidation`. - Every facet value is a parameterized `?`; no string concatenation of user values into SQL. - userID / userLibraryIDs come from the authenticated session only, never from params. ### Input bounds — CLEAN Handler caps each of `fauthor/fseries/fpublisher/flanguage/ftag` at 50 values and per-value length (1000 chars; 50 for language); count queries `LIMIT 100`. ### Migration 0047 — CLEAN Empty DDL (index-analysis placeholder). No new columns → no Grimmory schema violation. ### Templates / JS — CLEAN Facet chip `Label`/`RemoveURL` rendered via context-aware `{{.}}` (auto-escaped); no `template.HTML`/`| safe`. JS controller rebuilds a same-origin URL from checkbox names/values and `location.assign`s — no `innerHTML`, no inline `style=`. --- ### Findings [MAJOR] internal/books/service.go:105-128 — new list-filter facets + combinator are parsed but never applied (dead filters) The 5 new fields (`FacetAuthors/FacetSeries/FacetPublishers/FacetLanguages/FacetTags`) and `FacetCombinator` are parsed + validated in `ListHandler` and set on `Filter`, and the store predicates (`buildAuthorGroup`…`buildTagGroup`, combinator switch) are fully implemented — but the `Filter → ListBooksFilteredParams` mapping in `service.go` still stops at `FacetGenres` and does NOT copy the six new fields (`git grep 'FacetAuthors:'` in non-test code returns nothing). Result: selecting any of the 5 new facets or a combinator on the book list silently does nothing. This is a correctness/broken-feature bug, not a security leak (the well-scoped predicates never execute). Fix: add `FacetAuthors: filter.FacetAuthors, … FacetCombinator: filter.FacetCombinator` to the `ListBooksFilteredParams` literal in `service.go`. [MINOR] internal/books/facet_handler.go:174 — parseInt64Param loosens numeric validation `parseInt64Param` uses `fmt.Sscanf(s, "%d", &v)`, which accepts trailing garbage (e.g. `"12abc"` → 12, nil err) where the previous `strconv.ParseInt` rejected it. Not exploitable — these scope IDs are parameterized and intersected with the user's library scope, so a malformed/foreign value yields zero rows — but it weakens boundary validation. Fix: revert to `strconv.ParseInt(s, 10, 64)`. REVIEW VERDICT: 0 blocker, 1 major, 1 minor
fix(books): forward 6 new facet fields from Filter to store params
All checks were successful
/ Lint (pull_request) Successful in 4m21s
/ Integration (pull_request) Successful in 3m0s
/ JS Unit Tests (pull_request) Successful in 1m14s
/ E2E API (pull_request) Successful in 2m11s
/ Hugo build (pull_request) Successful in 1m27s
/ E2E Browser (pull_request) Successful in 3m29s
/ Test (pull_request) Successful in 6m55s
b8e37afe5e
The FacetAuthors/Series/Publishers/Languages/Tags/Combinator fields were
parsed from HTTP params in ListHandler and stored in Filter, but the
ListBooksFilteredParams literal in List() omitted them — so
appendFacetPredicates never received them and every author/series/
publisher/language/tag facet filter silently produced unfiltered results.

Fix: add the 6 missing field assignments to the params literal so the
predicates actually execute.

Also revert parseInt64Param from fmt.Sscanf (which accepts trailing
garbage like "12abc" → 12) to strconv.ParseInt (which rejects it).

Add 6 regression tests in service_test.go that capture ListBooksFiltered
Params and assert each new field is forwarded; these tests fail against
the unfixed code. Add a facet_handler_test case for "12abc" trailing-garbage
rejection.

Closes MAJOR finding from security review on PR #1129.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Filter Drawer screenshot (filter-drawer-chips-combinator)

Books list filter drawer — Genre facet expanded, filter applied.

filter-drawer-chips-combinator

**Filter Drawer screenshot** (filter-drawer-chips-combinator) Books list filter drawer — Genre facet expanded, filter applied. ![filter-drawer-chips-combinator](/attachments/d81eb3ba-bffb-4d9c-a65f-1ad07d258dd7)
Author
Owner

Filter Drawer screenshot (filter-drawer-chip-removed)

Books list filter drawer — Genre facet expanded, filter applied.

filter-drawer-chip-removed

**Filter Drawer screenshot** (filter-drawer-chip-removed) Books list filter drawer — Genre facet expanded, filter applied. ![filter-drawer-chip-removed](/attachments/d37c1e6e-f50c-45c3-a7b4-2ff0083cee44)
Author
Owner

Filter Drawer screenshot (filter-drawer-genre-expanded)

Books list filter drawer — Genre facet expanded, filter applied.

filter-drawer-genre-expanded

**Filter Drawer screenshot** (filter-drawer-genre-expanded) Books list filter drawer — Genre facet expanded, filter applied. ![filter-drawer-genre-expanded](/attachments/d78d7c3c-6257-4107-8026-dcfc4adecd3a)
Author
Owner

Filter Drawer screenshot (filter-drawer-fantasy-filtered)

Books list filter drawer — Genre facet expanded, filter applied.

filter-drawer-fantasy-filtered

**Filter Drawer screenshot** (filter-drawer-fantasy-filtered) Books list filter drawer — Genre facet expanded, filter applied. ![filter-drawer-fantasy-filtered](/attachments/a66b5a97-7634-4163-82b1-5080ed5db8d9)
zombor force-pushed bd-bookshelf-lj69z.2 from b8e37afe5e
All checks were successful
/ Lint (pull_request) Successful in 4m21s
/ Integration (pull_request) Successful in 3m0s
/ JS Unit Tests (pull_request) Successful in 1m14s
/ E2E API (pull_request) Successful in 2m11s
/ Hugo build (pull_request) Successful in 1m27s
/ E2E Browser (pull_request) Successful in 3m29s
/ Test (pull_request) Successful in 6m55s
to 4885d5284a
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m25s
/ E2E API (pull_request) Successful in 2m47s
/ Integration (pull_request) Successful in 3m37s
/ Lint (pull_request) Successful in 3m46s
/ E2E Browser (pull_request) Successful in 4m36s
/ Test (pull_request) Successful in 7m7s
2026-07-13 15:03:27 +00:00
Compare
zombor merged commit e1f892c630 into main 2026-07-13 15:11:42 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!1129
No description provided.