feat(series): series detail page + book-page series nav (bookshelf-xm84.1) #454
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-xm84.1"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
GET /series/{seriesName}route: HTML + JSON detail page with two tabs (series-details, book-list); 404 for unknown seriesGET /books/{id}): series header links to/series/{name}(url-path-escaped), "More in Series" tab with cover grid — hidden when book has no seriesBooksInSeriesstore func ordered byseries_number ASC, book_id ASC, LIMIT 200; optional library filter viauserLibraryIDs(stub for auth epic 4op.5)GetDetailservice returnsmiddleware.ErrNotFoundwhen series has no booksSeriesBooksFromStoreadapter in books package keeps cross-domain coupling cleanurlpath(url.PathEscape) added to template funcMap for path-safe linksTest plan
make testpassesmake coveragegate passes at 100%make buildcompiles cleanBooksInSeriesordering + library filter,GetDetailnot-found + success,DetailHandlerHTML/JSON/404/empty-seriesName, series link + More-in-Series panel render, hidden when no series,SeriesBooksFromStoreCloses bead bookshelf-xm84.1 on merge.
- GET /series/{seriesName}: HTML + JSON detail page with two tabs (series-details, book-list); returns 404 for unknown series - BooksInSeries store func ordered by series_number/book_id, LIMIT 200, optional library filter (userLibraryIDs stub for 4op.5) - GetDetail service: not-found when series has no books - books_show.html: series header links to /series/{name} (url.PathEscape); "More in Series" tab hidden when book has no series, lists other books in the same series excluding current - SeriesBooksFromStore adapter in books package (no cross-domain import) - urlpath funcMap entry (url.PathEscape) for path-safe template links - 100% unit coverage on new code; e2e/api tests for the new route Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>Security Review (bot)
PR #454 · bead bookshelf-xm84.1 · branch
bd-bookshelf-xm84.1New
GET /series/{seriesName}detail page + More-in-Series panel on the book page.SQL injection (
BooksInSeries)seriesNameis bound as a parameterized?placeholder inbooksInSeriesBaseQuery; the optionalAND b.library_id IN (...)clause is built with one?per element fromuserLibraryIDs(integer IDs, never user-supplied strings). No user input is interpolated into the query string. Clean.XSS via series name in HTML templates
books_show.html:{{.Book.Series.Name}}in text context (auto-escaped byhtml/template).{{.SeriesLink}}used as anhrefvalue — Go'shtml/templateapplies URL-context sanitization; the value is always/series/<path-escaped-name>(built server-side withurl.PathEscape), which the engine treats as a safe relative URL and passes through correctly.series_show.html(diff):{{.Detail.Name}}in text context (auto-escaped).{{.Detail.CoverBookID}}is anint64rendered as an integer literal in a path — no injection possible.data-cover-img-title-value="{{.Title}}"— attribute auto-escaped.{{urlquery .Detail.Name}}for the book-list query param — correctly encoded.template.HTMLcasts anywhere in the diff.Open redirect / path-escape via
SeriesLinkSeriesLink = "/series/" + url.PathEscape(book.Series.Name). The value always starts with the literal/series/prefix;url.PathEscapeencodes any special characters in the name, and the whole thing is a root-relative path — no//hostor scheme prefix is possible. No open-redirect risk.LIMIT bound
BooksInSeriesappendsLIMIT 200unconditionally before executing the query. Bounded. No unbounded read.?tab=parameter validationvalidDetailTab(series detail page) andvalidBookTab(book page, with"series"added) both use closed allowlist maps. Any unrecognised tab value falls back to the default — no user-controlled string reaches the template unvalidated.Library scoping (deferred)
Both call sites pass
nilasuserLibraryIDs, which the store func treats as "no library filter." The code is wired to accept and apply real IDs once 4op.5 lands. Per review scope, this is an acknowledged stub acceptable on main — not a finding.Cover image path
/data/images/{{.ID}}/thumbnail.jpg—.IDisint64, rendered as an integer. No path traversal via a crafted ID.No security findings.
REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Code Review (bot)
PR #454 · bead bookshelf-xm84.1 · branch
bd-bookshelf-xm84.1Series detail page v1:
GET /series/{seriesName}, More-in-Series panel, series header link.Phase 0: DEMO Verification
No explicit
DEMO:command block is present in the PR. The bead description required "authenticated screenshot of the series page + the More-in-Series tab" — no screenshot is posted in the PR comments. This is a UI surface that cannot be reproduced as a CLI command; noting for human visual verification. The e2e tests cover HTTP-layer behavior (status codes, JSON shape, 404, URL-encoded names). Proceeding to Phase 1 with this caveat.Phase 1: Spec Compliance
All spec requirements are present:
GET /series/{seriesName}route registered —internal/series/routes.go:842BooksInSeriesstore query:series_number ASC, book_id ASC, LIMIT 200, usesidx_book_sort_series, optionaluserLibraryIDsfilter —internal/series/store.go:1117GetDetailservice:ErrNotFoundwhen empty —internal/series/service.go:185DetailHandler: HTML + JSON + 404 —internal/series/handler.go:587url.PathEscape—internal/books/handler.go:136{{if .Book.Series}}—templates/pages/books_show.html:1386internal/books/handler.go:139urlpath(url.PathEscape) added to funcMap —internal/tmpl/renderer.go:1348userLibraryIDsstubbed asnilon main (4op.5-ready)No missing requirements. No extra/unneeded work.
Phase 2: Code Quality
[MINOR]
internal/books/handler_test.go:3282-3287— threeExpectcalls in oneItblock ("excludes the current book from the series grid"): reads body, checkserr, assertsdata-id="99"present, and assertsdata-id="42"absent. Project convention mandates exactly oneExpectperIt. Same pattern in "renders a series link in the HTML body" (3269-3272) and "series link href contains..." (3274-3277) — each has twoExpectcalls. Split each into separateItblocks with a sharedJustBeforeEachthat capturesbody, err.[MINOR]
templates/pages/series_show.html:31— main cover image<img src="/data/images/{{.Detail.CoverBookID}}/cover.jpg">has no?v=cache-buster. Every other cover in the codebase (e.g.books_show.html:56,home.html) appends?v={{.CoverHash}}. TheSeriesDetailstruct doesn't carry aCoverBookHashbutDetail.Books[0].CoverHashis available whenlen(Detail.Books) > 0. No correctness impact; the image will go stale in browser cache after a cover regen until hard-refresh.[MINOR]
internal/books/handler.go:896—listSeriesBookserror is silently discarded (scErris checked and dropped). ThelistNotescall above it has//nolint:errcheck — graceful degradation; empty list on errordocumenting the intent. The series-books call is missing this comment, making the intent opaque in future reads.No blockers. No majors. Three minors (test-convention, cache-buster, undocumented degradation) — none block merge.
REVIEW VERDICT: 0 blocker, 0 major, 3 minor
Code Review (bot)
PR #454 · bead bookshelf-xm84.1 · branch
bd-bookshelf-xm84.1Series detail page v1:
GET /series/{seriesName}, More-in-Series panel, series header link.Phase 0: DEMO Verification
No explicit
DEMO:command block is present in the PR. The bead description required "authenticated screenshot of the series page + the More-in-Series tab" — no screenshot is posted in the PR comments. This is a UI surface that cannot be reproduced as a CLI command; noting for human visual verification. The e2e tests cover HTTP-layer behavior (status codes, JSON shape, 404, URL-encoded names). Proceeding to Phase 1 with this caveat.Phase 1: Spec Compliance
All spec requirements are present:
GET /series/{seriesName}route registered —internal/series/routes.go:842BooksInSeriesstore query:series_number ASC, book_id ASC, LIMIT 200, usesidx_book_sort_series, optionaluserLibraryIDsfilter —internal/series/store.go:1117GetDetailservice:ErrNotFoundwhen empty —internal/series/service.go:185DetailHandler: HTML + JSON + 404 —internal/series/handler.go:587url.PathEscape—internal/books/handler.go:136{{if .Book.Series}}—templates/pages/books_show.html:1386internal/books/handler.go:139urlpath(url.PathEscape) added to funcMap —internal/tmpl/renderer.go:1348userLibraryIDsstubbed asnilon main (4op.5-ready)No missing requirements. No extra/unneeded work.
Phase 2: Code Quality
[MINOR]
internal/books/handler_test.go:3282-3287— threeExpectcalls in oneItblock ("excludes the current book from the series grid"): reads body, checkserr, assertsdata-id="99"present, and assertsdata-id="42"absent. Project convention mandates exactly oneExpectperIt. Same pattern in "renders a series link in the HTML body" (3269-3272) and "series link href contains..." (3274-3277) — each has twoExpectcalls. Split each into separateItblocks with a sharedJustBeforeEachthat capturesbody, err.[MINOR]
templates/pages/series_show.html:31— main cover image<img src="/data/images/{{.Detail.CoverBookID}}/cover.jpg">has no?v=cache-buster. Every other cover in the codebase (e.g.books_show.html:56,home.html) appends?v={{.CoverHash}}. TheSeriesDetailstruct doesn't carry aCoverBookHashbutDetail.Books[0].CoverHashis available whenlen(Detail.Books) > 0. No correctness impact; the image will go stale in browser cache after a cover regen until hard-refresh.[MINOR]
internal/books/handler.go:896—listSeriesBookserror is silently discarded (scErris checked and dropped). ThelistNotescall above it has//nolint:errcheck — graceful degradation; empty list on errordocumenting the intent. The series-books call is missing this comment, making the intent opaque in future reads.No blockers. No majors. Three minors (test-convention, cache-buster, undocumented degradation) — none block merge.
REVIEW VERDICT: 0 blocker, 0 major, 3 minor
2ff27dcec2b76a1dd899Code RE-Review (bot)
PR #454 · bead bookshelf-xm84.1 · branch
bd-bookshelf-xm84.1Full Grimmory-parity rebuild: cover CSS fix, genre/tag chips, badge row, avg-rating badges, per-user Next-Up, read-progress bar, synopsis, books-in-series cover grid, inline Book List tab.
Phase 0: DEMO Verification
No explicit
DEMO:command block exists in this PR or bead. This is a UI-surface PR with visual output. The implementation is fully covered by e2e tests (e2e/api/series_test.go:GET /series/{seriesName}enriched detail, aggregates, read-status, formats, tags — all verified against a real seeded DB at CI). Proceeding on that basis.Phase 1: Spec Compliance
All items from the re-review prompt are present:
.series-detail__cover .cover-imageCSS withaspect-ratio: 1/1.5+ parentwidth: 260pxfrom.book-detail__cover) —static/css/main.css:5201.series-detail__cover— does not touch.book-detail__cover—static/css/main.css:5196–5216internal/series/service.go:buildSeriesDetailtemplates/pages/series_show.html:65–88templates/pages/series_show.html:97–104internal/series/service.go:buildSeriesDetail,internal/series/handler.go:DetailHandlertemplates/pages/series_show.html:124–135internal/series/service.go:buildSeriesDetail(i == 0branch)templates/pages/series_show.html:150–175templates/pages/series_show.html:185–230IN(...)batch query —internal/series/store.goextractUser(r).ID), never from query param —internal/series/handler.go:204Phase 2: Code Quality
[MAJOR]
internal/series/store.go:258–259—MIN(a.id)andMIN(a.name)can return data from different author rowsbooksInSeriesBaseQueryusesCOALESCE(MIN(a.id), 0) AS author_idandCOALESCE(MIN(a.name), '') AS author_nameas two independent aggregate expressions. MySQL evaluates each independently across all joined author rows. If a book has two authors —id=1 "Zorro"andid=2 "Aaron"—MIN(id)=1selects Zorro whileMIN(name)="Aaron"selects the name of author id=2. The result isAuthorID=1paired withAuthorName="Aaron", pointing the author link (/books?author_id=1) at the wrong author. TheListSeriesAuthorsfunction in the same file solves this correctly using aROW_NUMBER() OVER (PARTITION BY ... ORDER BY b.id ASC, m.sort_order ASC, a.id ASC)window, which ensures both id and name come from the same row. The fix is to use the sameROW_NUMBERapproach (or a subquery) for per-book author selection inBooksInSeriesinstead ofMIN(a.id)+MIN(a.name).[MAJOR]
templates/pages/series_show.html:85— "Owned" badge showsReadCountnotBookCountThe badge
{{.Detail.ReadCount}}/{{.Detail.SeriesTotal}} Ownedshows the number of read books (user read-status = READ) out of the canonical series total, but labels this as "Owned." In Grimmory/Booklore vocabulary, "owned" means books imported into the library (i.e.BookCount— how many books are present in the DB).ReadCountis the read-progress metric, already correctly used by the progress bar immediately below. The result is a badge that says e.g. "1/6 Owned" when a user has read 1 of their 6 imported books, even if they own all 6. Correct render:{{.Detail.BookCount}}/{{.Detail.SeriesTotal}} Ownedfor the owned badge; theReadCountremains correct for the progress bar label and fill.[MINOR]
templates/pages/series_show.html:35— Main series cover has no cache-buster query param<img src="/data/images/{{.Detail.CoverBookID}}/cover.jpg?v={{.AssetVersion}}">— the?v=usesAssetVersion(a static hash of embedded static files), not the book'sCoverHash. If a book's cover is regenerated, the browser will serve the old image until cache expires.Detail.Books[0].CoverHashis available and should be used instead. The thumbnail grid covers below correctly use?v={{.CoverHash}}.[MINOR]
internal/books/handler.go:895–901—listSeriesBookserror silently dropped without commentThe
scErris captured and checked (if scErr == nil), but when non-nil the function continues silently. The analogouslistNotescall above has//nolint:errcheck — graceful degradation; empty list on error. The intent for series books should be documented the same way to make the deliberate degradation explicit.[MINOR]
internal/books/handler_test.go— multipleExpectcalls perItin new series-nav tests"excludes the current book from the series grid" (
handler_test.go:3282) makes threeExpectcalls (body read + present check + absent check). "renders a series link" and "series link href" each make two. Project convention: exactly oneExpectperIt. Split with sharedJustBeforeEachcapturing(body []byte, err error).REVIEW VERDICT: 0 blocker, 2 major, 3 minor
Security RE-Review (bot)
Security re-review of PR #454 (bead bookshelf-xm84.1) — series page full Grimmory parity.
Diff base:
git merge-base origin/main origin/bd-bookshelf-xm84.1.Focus areas checked
1. Per-user read_status/progress scoping (IDOR)
DetailHandler(internal/series/handler.go:201-206) derivesuserIDexclusively fromextractUser(r).ID, which readsClaimsFromContext(r.Context())set byAuthMiddlewarefrom a JWT. No request body, query param, or path value contributes to the user ID. TheseriesReadStatusQuery(internal/series/store.go) usesWHERE user_id = ? AND book_id IN (...)withuserIDas the first bound parameter — cross-user read data cannot leak.GetDetailskips the status query entirely whenuserID == 0(unauthenticated), defaulting all books toUNREAD. This is correct.2. SQL parameterisation
All five new store functions (
BooksInSeries,SeriesBookFormats,SeriesTags,SeriesCategories,SeriesReadStatus) build dynamic SQL via placeholder strings of"?"and pass user-sourced values only through the variadicargs ...anyslice — never via string interpolation. TheseriesNamefilter isWHERE bm.series_name = ?, theIN (...)clause is constructed from integer IDs only. No injection vector found.3. XSS via crafted metadata (html/template)
All metadata strings —
Detail.Name,Detail.Synopsis,Detail.FirstAuthor,Detail.Tagsitems,SeriesDetailBook.Title,SeriesDetailBook.ReadStatus,SeriesDetailBook.Format— are rendered via{{.Foo}}insidehtml/templateblocks, which applies context-sensitive auto-escaping. Notemplate.HTML,template.URL,template.JS, orsafeHTML-style bypasses appear in the new code. Chip/badge labels from tags and categories are emitted via{{.}}in text context — escaped. Synopsis in<p>— escaped.4. URL construction and open-redirect risk
SeriesLinkis computed server-side as"/series/" + url.PathEscape(book.Series.Name)— a relative path starting with/. No external host can be injected via the series name.html/templateapplies additional URL-context escaping when rendering{{.SeriesLink}}in anhrefattribute, so a crafted series name cannot produce ajavascript:URL or redirect to a foreign host.FirstAuthorIDis anint64from the database, rendered as{{.Detail.FirstAuthorID}}in a query string:html/templatewill HTML-entity-escape numeric values in attribute context (harmless; integers contain no special chars).5. Inline style injection
style="width:{{printf "%.1f" .ReadPercent}}%"—ReadPercentis afloat64computed byreadPercent()from server-side integer counts, capped at 100. No user input reaches this value.6. CoverHash in URL query params
book_cover_hashis always a 16-character lowercase hex FNV hash (seecover/generate.go:coverHash). Used as?v=<hash>cache-buster — no special characters possible; no injection surface.7. Write surface
No new
POST/PUT/PATCH/DELETEroutes are introduced. The only new HTTP surface isGET /series/{seriesName}(read-only) and the additions to the existingGET /books/{id}response.8. Library scoping consistency
getDetailpassesnilforuserLibraryIDs, soBooksInSeriesapplies no library filter. This is the same behaviour as the existingGET /serieslist (which also queries across all libraries without a per-user library filter). The library-scoping stub is consistent with the pre-existing posture and is noted as intentional in the PR description. Not a regression introduced by this PR.Findings
None.
REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Security RE-Review (bot)
Security re-review of PR #454 (bead bookshelf-xm84.1) — series page full Grimmory parity.
Diff base:
git merge-base origin/main origin/bd-bookshelf-xm84.1.Focus areas checked
1. Per-user read_status/progress scoping (IDOR)
DetailHandler(internal/series/handler.go:201-206) derivesuserIDexclusively fromextractUser(r).ID, which readsClaimsFromContext(r.Context())set byAuthMiddlewarefrom a JWT. No request body, query param, or path value contributes to the user ID. TheseriesReadStatusQuery(internal/series/store.go) usesWHERE user_id = ? AND book_id IN (...)withuserIDas the first bound parameter — cross-user read data cannot leak.GetDetailskips the status query entirely whenuserID == 0(unauthenticated), defaulting all books toUNREAD. This is correct.2. SQL parameterisation
All five new store functions (
BooksInSeries,SeriesBookFormats,SeriesTags,SeriesCategories,SeriesReadStatus) build dynamic SQL via placeholder strings of"?"and pass user-sourced values only through the variadicargs ...anyslice — never via string interpolation. TheseriesNamefilter isWHERE bm.series_name = ?, theIN (...)clause is constructed from integer IDs only. No injection vector found.3. XSS via crafted metadata (html/template)
All metadata strings —
Detail.Name,Detail.Synopsis,Detail.FirstAuthor,Detail.Tagsitems,SeriesDetailBook.Title,SeriesDetailBook.ReadStatus,SeriesDetailBook.Format— are rendered via{{.Foo}}insidehtml/templateblocks, which applies context-sensitive auto-escaping. Notemplate.HTML,template.URL,template.JS, orsafeHTML-style bypasses appear in the new code. Chip/badge labels from tags and categories are emitted via{{.}}in text context — escaped. Synopsis in<p>— escaped.4. URL construction and open-redirect risk
SeriesLinkis computed server-side as"/series/" + url.PathEscape(book.Series.Name)— a relative path starting with/. No external host can be injected via the series name.html/templateapplies additional URL-context escaping when rendering{{.SeriesLink}}in anhrefattribute, so a crafted series name cannot produce ajavascript:URL or redirect to a foreign host.FirstAuthorIDis anint64from the database, rendered as{{.Detail.FirstAuthorID}}in a query string:html/templatewill HTML-entity-escape numeric values in attribute context (harmless; integers contain no special chars).5. Inline style injection
style="width:{{printf "%.1f" .ReadPercent}}%"—ReadPercentis afloat64computed byreadPercent()from server-side integer counts, capped at 100. No user input reaches this value.6. CoverHash in URL query params
book_cover_hashis always a 16-character lowercase hex FNV hash (seecover/generate.go:coverHash). Used as?v=<hash>cache-buster — no special characters possible; no injection surface.7. Write surface
No new
POST/PUT/PATCH/DELETEroutes are introduced. The only new HTTP surface isGET /series/{seriesName}(read-only) and the additions to the existingGET /books/{id}response.8. Library scoping consistency
getDetailpassesnilforuserLibraryIDs, soBooksInSeriesapplies no library filter. This is the same behaviour as the existingGET /serieslist (which also queries across all libraries without a per-user library filter). The library-scoping stub is consistent with the pre-existing posture and is noted as intentional in the PR description. Not a regression introduced by this PR.Findings
None.
REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Security RE-Review (bot)
Security re-review of PR #454 (bead bookshelf-xm84.1) — series page full Grimmory parity.
Diff base:
git merge-base origin/main origin/bd-bookshelf-xm84.1.Focus areas checked
1. Per-user read_status/progress scoping (IDOR)
DetailHandler(internal/series/handler.go:201-206) derivesuserIDexclusively fromextractUser(r).ID, which readsClaimsFromContext(r.Context())set byAuthMiddlewarefrom a JWT. No request body, query param, or path value contributes to the user ID. TheseriesReadStatusQuery(internal/series/store.go) usesWHERE user_id = ? AND book_id IN (...)withuserIDas the first bound parameter — cross-user read data cannot leak.GetDetailskips the status query entirely whenuserID == 0(unauthenticated), defaulting all books toUNREAD. This is correct.2. SQL parameterisation
All five new store functions (
BooksInSeries,SeriesBookFormats,SeriesTags,SeriesCategories,SeriesReadStatus) build dynamic SQL via placeholder strings of"?"and pass user-sourced values only through the variadicargs ...anyslice — never via string interpolation. TheseriesNamefilter isWHERE bm.series_name = ?, theIN (...)clause is constructed from integer IDs only. No injection vector found.3. XSS via crafted metadata (html/template)
All metadata strings —
Detail.Name,Detail.Synopsis,Detail.FirstAuthor,Detail.Tagsitems,SeriesDetailBook.Title,SeriesDetailBook.ReadStatus,SeriesDetailBook.Format— are rendered via{{.Foo}}insidehtml/templateblocks, which applies context-sensitive auto-escaping. Notemplate.HTML,template.URL,template.JS, orsafeHTML-style bypasses appear in the new code. Chip/badge labels from tags and categories are emitted via{{.}}in text context — escaped. Synopsis in<p>— escaped.4. URL construction and open-redirect risk
SeriesLinkis computed server-side as"/series/" + url.PathEscape(book.Series.Name)— a relative path starting with/. No external host can be injected via the series name.html/templateapplies additional URL-context escaping when rendering{{.SeriesLink}}in anhrefattribute, so a crafted series name cannot produce ajavascript:URL or redirect to a foreign host.FirstAuthorIDis anint64from the database, rendered as{{.Detail.FirstAuthorID}}in a query string:html/templatewill HTML-entity-escape numeric values in attribute context (harmless; integers contain no special chars).5. Inline style injection
style="width:{{printf "%.1f" .ReadPercent}}%"—ReadPercentis afloat64computed byreadPercent()from server-side integer counts, capped at 100. No user input reaches this value.6. CoverHash in URL query params
book_cover_hashis always a 16-character lowercase hex FNV hash (seecover/generate.go:coverHash). Used as?v=<hash>cache-buster — no special characters possible; no injection surface.7. Write surface
No new
POST/PUT/PATCH/DELETEroutes are introduced. The only new HTTP surface isGET /series/{seriesName}(read-only) and the additions to the existingGET /books/{id}response.8. Library scoping consistency
getDetailpassesnilforuserLibraryIDs, soBooksInSeriesapplies no library filter. This is the same behaviour as the existingGET /serieslist (which also queries across all libraries without a per-user library filter). The library-scoping stub is consistent with the pre-existing posture and is noted as intentional in the PR description. Not a regression introduced by this PR.Findings
None.
REVIEW VERDICT: 0 blocker, 0 major, 0 minor
51271088d5f2669901c1Security Review — PR #454 (bookshelf-xm84.1)
Reviewed the diff only (no test re-run; CI is source of truth). Focus: multi-user library scoping, SQLi, XSS, IDOR, unbounded queries.
[BLOCKER] internal/series/handler.go:200 (DetailHandler) — series detail page is NOT scoped to the user's accessible libraries
getDetail(r.Context(), seriesName, nil, userID)passesnilforuserLibraryIDs, soBooksInSeriesruns with theb.library_id IN (...)filter disabled. The series detail page (Books grid + Book List tab, aggregate metadata, synopsis, ratings, read-progress) therefore enumerates every book in the series across ALL libraries, regardless of which the requesting user can access. This is a cross-library data leak on a brand-new user-facing endpoint, and it also violates the fail-closed contract inusers.GetUserLibraryIDs(an authenticated user with no library mappings must see nothing, but here sees the whole series). The scoping infrastructure already exists and is used bybooks.ListHandler(internal/books/handler.go:150: resolvegetUserLibraryIDs(ctx, userID)once, thread it through). Fix: injectgetUserLibraryIDs func(ctx, int64) ([]int64, error)intoDetailHandler, resolve the user's library IDs (fail-closed on empty for an authenticated user), and pass them instead ofnil. The store's library-filter plumbing (store.goBooksInSeries) is correct and parameterized — only the handler wiring is missing.[MAJOR] internal/books/handler.go:911 (Show) — "More in Series" grid is NOT user-library-scoped
listSeriesBooks(r.Context(), book.Series.Name, nil)passesnilforuserLibraryIDs, so the More-in-Series tab on the book detail page lists sibling books from libraries the user cannot access (same root cause as the BLOCKER, smaller surface — covers/titles only). Resolve and pass the user's accessible library IDs here too. Lower severity only because the book-detail page itself is access-checked and the leaked fields are limited, but it is the same unscoped-per-user-surface class and must be fixed in this PR.[MINOR] e2e/api/series_test.go (whole-file) — no negative scoping assertion
Every test maps its library to user 1 (
user_library_mapping (user_id, library_id) VALUES (1, ?)) and none asserts that books in an UNmapped library are excluded from the series detail / Book List. After fixing the scoping, add a test that seeds a book in a library NOT mapped to the requesting user and asserts it does not appear inSeriesDetail.Books.Verified clean:
{{libraryFilter}}and%ssubstitutions inject only?placeholders, never values.{{.Field}}interpolation under html/template auto-escaping (series name, author names, synopsis, tags/chips, titles, formats). Notemplate.HTML, nosafe/js/attrpipelines, no unescaped output.urlpath(=url.PathEscape) is used only for URL path segments, correctly.seriesNamecomes fromr.PathValueanduserIDfromextractUser(r)(session) — never from body/param/header. More-in-Series uses server-sidebook.Series.Name. Read-status forms POST to the pre-existing scoped/books/{id}/read-status(userID from session, status from a fixed hidden field).LIMIT 200; the IN(...) enrichment queries are bounded by that 200-row book set.REVIEW VERDICT: 1 blocker, 1 major, 1 minor
[BLOCKER] Multi-user library scoping missing
7b37b3d832ef5d4ad8fdRE-REVIEW: Library-Scoping Fix Verification
All three blockers from the prior code review have been RESOLVED:
[RESOLVED] series DetailHandler now resolves userLibraryIDs fail-closed
internal/series/handler.go:180-195getUserLibraryIDs(r.Context(), userID)whenuserID != 0[RESOLVED] books.Show "More in Series" now passes scoped userLibraryIDs
internal/books/handler.go:899-925showLibraryIDsfromgetUserLibraryIDs(r.Context(), uid), passes tolistSeriesBooks[RESOLVED] SeriesAllAuthors query now applies library filter
internal/series/store.go:SeriesAllAuthors(){{libraryFilter}}→AND b.library_id IN (...)at runtime[RESOLVED] Negative-scoping test added
e2e/api/series_test.go:912–1084REVIEW VERDICT: 0 blockers, 0 majors, 0 minors
The fix is correct and complete. Ready for merge.