feat(authors): author detail page + book-page author links (bookshelf-37ef.1) #455
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-37ef.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 /authors/{id}page (HTML + JSON) with 3-tab shell (Author Details, Edit stub, Search stub)/authors/{id}instead of being plain textBook.Authors/ListBook.Authorschanged from[]stringto[]AuthorRef{ID, Name}throughoutidx_bmap_author_idonbook_metadata_author_mappingfor efficient per-author book countsGetAuthorByIDsqlc query withbook_countviaLEFT JOIN … GROUP BYListBookAuthorsWithIDquery used by the book detailGetfor author ID+name in one queryTest plan
GetAuthorservice: maps all fields, returnsErrNotFoundon sql.ErrNoRows, propagates unexpected errors, handles null name/descriptionShowHandler: HTML renders author name + book count; JSON returnsAuthorDetail; invalid ID → 400; not found → 404ListAuthorsForBooksstore: updated for 3-column scan (book_id, author_id, name)books.*+home.*tests updated for[]AuthorReftype changemake testpasses (all 2100+ specs green)make lintpasses (0 issues)Closes bead bookshelf-37ef.1 on merge.
- Add GetAuthorByID sqlc query with book_count via LEFT JOIN + GROUP BY - Add covering index idx_bmap_author_id on book_metadata_author_mapping - Add AuthorDetail domain type (description, ASIN, locked fields, book_count) - Add GetAuthor service (ErrNotFound on sql.ErrNoRows) - Add ShowHandler for GET /authors/{id} with HTML/JSON content negotiation - Add author_show.html 3-tab template (details populated, edit/search stubs) - Change Book.Authors + ListBook.Authors from []string to []AuthorRef{ID, Name} - Add ListBookAuthorsWithID sqlc query; wire into Get for author-ID links - Update buildListAuthorsQuery to SELECT a.id alongside a.name - Update all templates to use .ID/.Name on AuthorRef (book-page links to /authors/{id}) - Update all existing tests; add GetAuthor + ShowHandler test coverage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>885c3490dfa5d049045eSecurity Review (bot)
PR #455 — bookshelf-37ef.1: GET /authors/{id} detail page + author links on book pages
Scope
Read-only feature: new
GET /authors/{id}detail page (HTML + JSON), author name links on book list/show pages changed from/books?author=<string>to/authors/{id}.Findings
No security findings.
SQL injection —
GetAuthorByIDuses a sqlc-generated prepared statement withWHERE a.id = ?; theidis parsed toint64viastrconv.ParseIntbefore it ever touches the DB. The dynamicbuildListAuthorsQuery(for book list) usesfmt.Sprintfwith a placeholder-count calculated fromlen(ids)and passes all values as[]any— not inline-interpolated. No injection surface.XSS — All author fields (Name, Description, ASIN, BookCount, ID) are rendered through
html/template, which HTML-escapes all{{...}}interpolations automatically. Notemplate.HTML,template.URL,template.JS, or similar unsafe casts were introduced. Thealtattribute (alt="Photo of {{.Author.Name}}") and thetitleattribute on the authors table cell are both underhtml/templateescaping. IntegerAuthor.IDis interpolated intohrefandsrcattributes — asint64, it cannot carry a payload.404 does not leak existence information —
GetAuthortranslatessql.ErrNoRowstomiddleware.ErrNotFound; theErrorHandlersends backhttp.StatusText(404)("Not Found") without surfacing the internal error string.Error messages —
ErrorHandler.writeErroralways useshttp.StatusText(status)as the response body; the real error is only sent to slog/Seq. No stack traces or internal detail leak to callers.Input validation — Invalid path segment (
/authors/notanumber) returnsErrValidation→ 400, covered by a unit test. No path traversal surface (route uses Go 1.22{id}wildcard, resolved byr.PathValue).Lock flags in JSON response —
AuthorDetailexposesname_locked,description_locked,asin_locked,photo_lockedin the JSON path. These flags are not rendered in the HTML template; they are plumbing for the v2 Edit UI. No auth gates the existingGET /authorslist endpoint either, so the new endpoint's auth posture is consistent with the pre-existing surface. The flags do not convey PII or credentials. Not a finding.No new write/mutation surface — This PR is read-only. The "Edit Details" and "Search Author" panels are stubs with no forms, no POST routes, no input processing.
books?author_id= link — The
/books?author_id={{.Author.ID}}link inauthor_show.htmluses the integerAuthor.ID(not a user-supplied string). The handler-side parse ofauthor_idviastrconv.ParseIntpre-dates this PR.REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Security Review (bot)
PR #455 — bookshelf-37ef.1: GET /authors/{id} detail page + author links on book pages
Scope
Read-only feature: new
GET /authors/{id}detail page (HTML + JSON), author name links on book list/show pages changed from/books?author=<string>to/authors/{id}.Findings
No security findings.
SQL injection —
GetAuthorByIDuses a sqlc-generated prepared statement withWHERE a.id = ?; theidis parsed toint64viastrconv.ParseIntbefore it ever touches the DB. The dynamicbuildListAuthorsQuery(for book list) usesfmt.Sprintfwith a placeholder-count calculated fromlen(ids)and passes all values as[]any— not inline-interpolated. No injection surface.XSS — All author fields (Name, Description, ASIN, BookCount, ID) are rendered through
html/template, which HTML-escapes all{{...}}interpolations automatically. Notemplate.HTML,template.URL,template.JS, or similar unsafe casts were introduced. Thealtattribute (alt="Photo of {{.Author.Name}}") and thetitleattribute on the authors table cell are both underhtml/templateescaping. IntegerAuthor.IDis interpolated intohrefandsrcattributes — asint64, it cannot carry a payload.404 does not leak existence information —
GetAuthortranslatessql.ErrNoRowstomiddleware.ErrNotFound; theErrorHandlersends backhttp.StatusText(404)("Not Found") without surfacing the internal error string.Error messages —
ErrorHandler.writeErroralways useshttp.StatusText(status)as the response body; the real error is only sent to slog/Seq. No stack traces or internal detail leak to callers.Input validation — Invalid path segment (
/authors/notanumber) returnsErrValidation→ 400, covered by a unit test. No path traversal surface (route uses Go 1.22{id}wildcard, resolved byr.PathValue).Lock flags in JSON response —
AuthorDetailexposesname_locked,description_locked,asin_locked,photo_lockedin the JSON path. These flags are not rendered in the HTML template; they are plumbing for the v2 Edit UI. No auth gates the existingGET /authorslist endpoint either, so the new endpoint's auth posture is consistent with the pre-existing surface. The flags do not convey PII or credentials. Not a finding.No new write/mutation surface — This PR is read-only. The "Edit Details" and "Search Author" panels are stubs with no forms, no POST routes, no input processing.
books?author_id= link — The
/books?author_id={{.Author.ID}}link inauthor_show.htmluses the integerAuthor.ID(not a user-supplied string). The handler-side parse ofauthor_idviastrconv.ParseIntpre-dates this PR.REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Code Review (bot)
Phase 0: DEMO Verification
No DEMO block was provided in the bead description or comments. The bead spec calls for "Authenticated screenshot of author page + book-page author links" but no DEMO command block was included. Project convention requires DEMO verification; proceeding to code review since CI is green and this is a code-only (no interactive session) review context.
Phase 1 & 2: Findings
[MAJOR] e2e/api/authors_test.go — missing e2e test for GET /authors/{id}
Project conventions (project-conventions.md) state: "Every HTTP route gets at least one e2e test in
e2e/api/covering status code, content type, and key body assertions." The PR addsGET /authors/{id}(ShowHandler) as a new route but the e2e test file has noDescribe("GET /authors/{id}"...)block. The existingGET /authors/{id}/phototest (pre-existing) is unrelated. The unit test ininternal/authors/handler_test.gocovers the handler logic well, but the wiring (route registration, real DB round-trip, content-type header on JSON response) is only validated by an e2e test. Fix: add aDescribe("GET /authors/{id}", ...)block toe2e/api/authors_test.gowith at minimum: 200 + JSON content-type for JSON accept, 200 + text/html for browser, 404 for unknown ID, andbook_countcorrect from real DB.[MINOR] internal/authors/handler_test.go:408-414 — ShowHandler test passes stubPhotoHandler as list slot
In the
ShowHandlerdescribe block'sJustBeforeEach,RegisterRoutesis called withstubPhotoHandlerin thelistposition andShowHandler(...)in theshowposition. The third arg (photo) also getsstubPhotoHandler. Since the tests only hitGET /authors/{id}and neverGET /authors, the list stub is never invoked and no assertion fails — but it's misleading. A reader scanning the test expects the first positional handler to be a list stub. Fix: pass a minimal stub list handler (orstubPhotoHandlerrenamed to clarify it's a no-op any-handler) in the list slot, or add a comment explaining the no-op choice.[MINOR] internal/authors/service.go:264 — GetAuthorByIDRow.Name is sql.NullString but author name is logically required
When
Name.Validis false the mapping silently returnsAuthorDetail{Name: ""}. A blank author name will render as an empty<h1>and a broken page title. The SQL query selectsa.namewhich in Grimmory schema isVARCHAR NOT NULL(all existing rows have non-null names), so in practice this won't fire. But the defensive check is asymmetric with the ID (always present) and it would be cleaner to either log a warning or treat a null name as a not-found. Low risk given schema constraints.REVIEW VERDICT: 0 blocker, 1 major, 2 minor
a5d049045e1d471e423d1d471e423d0ece33558cf5c858634eb133826250Security Review — PR #455 (bookshelf-37ef.1) author pages
Reviewed the diff only (no test re-run), focused on multi-user scoping, SQLi, IDOR, path traversal, and XSS.
[BLOCKER] internal/authors/service.go:271 — author→books enumeration is NOT scoped to the requesting user's accessible libraries (cross-library data leak)
ListAuthorBooksruns:There is no
b.library_id IN (...)predicate andShowHandler(internal/authors/show_handler.go:54 →listBooks(r.Context(), id)) passes nouserLibraryIDs. Any authenticated user visiting/authors/{id}sees titles + covers + book IDs (links to/books/{id}) for books in libraries they have NO access to. This is the identical defect to sibling #454's nil-userLibraryIDs series leak. The whole books surface fail-closes on this (internal/books/service.go:286, handler.go:150, store.go:340-348): resolveuserIDfrom session, callusers.GetUserLibraryIDs, thread the result (non-nil empty slice ⇒ deny all) into the query —AND b.library_id IN (?)when non-empty, return[]when empty-but-non-nil, skip only when nil (system/no-auth).Fix: add a
userLibraryIDs []int64parameter toListAuthorBooks, build theb.library_id IN (...)clause with the fail-closed semantics already used in books/store.go, and inShowHandlerresolve userID-from-session →getUserLibraryIDs→ pass it in (mirror books/handler.go:148-155). Add an e2e/service negative case: a book mapped to a library the user is NOT in must be absent from both theBooksgrid and ideally the count. (e2e/api/authors_test.go currently only seeds an accessible library for user 1, so the leak is untested.)[MAJOR] internal/db/queries/authors.sql:16 —
book_counton the detail page is a global, cross-library countGetAuthorByID'sCOUNT(m.book_id)counts every mapped book regardless of library access, and the template renders it as "Books by X (N)" (templates/pages/author_show.html:58). With the BLOCKER fixed, a user could see a heading count larger than the books actually listed, leaking the existence/volume of books in libraries they cannot access. Scope the count to the user's libraries (joinbook b/b.library_id IN (...)in the count) so the displayed total matches the accessible set. (The pre-existing/authorsLIST shares this aggregate-count behavior; that's prior art, but the new detail page makes the discrepancy user-visible against the now-scoped grid — fix here.)Clean (verified, no finding):
ListAuthorBooks,GetAuthorByID,ListBookAuthorsWithIDare all fully parameterized (?placeholders / sqlc); author id isstrconv.ParseInt'd (show_handler.go:43), no string-concat of id/name.r.PathValue("id")only; identity is never read from body/query/header./authors/{id}/photo): PhotoHandler is unchanged by this PR and safe — the filesystem path is built from the parsed int64 viafiles.AuthorImagePath, never raw input; placeholder SVG served on miss with a safe content type..Author.Name,.Description,.ASIN, book.Title) go through html/template auto-escaping; notemplate.HTML/safeHTMLbypass;slice .Title 0 1is guarded by{{if .Title}}.authorBooksLimit = 50— no unbounded query.REVIEW VERDICT: 1 blocker, 1 major, 0 minor
Security re-review (focused) — PR #455 / bookshelf-37ef.1
Re-verified the prior library-scoping findings against
origin/main...origin/bd-bookshelf-37ef.1. Most are resolved; one regression-of-scope remains on the JSON surface.Prior [BLOCKER] — ListAuthorBooks unscoped book grid: RESOLVED.
ListAuthorBooksnow takesuserLibraryIDs []int64with correct fail-closed semantics (service.go):nil→ no predicate (system path), non-nil-empty → returns[]AuthorBook{}(a user with zero library mappings sees nothing), non-nil →AND b.library_id IN (?, ...).users.GetUserLibraryIDsnormalises a no-mapping user to a non-nil empty slice, so authenticated-but-unmapped users correctly fail closed.ShowHandlerresolvesuserID := userIDFromRequest(r)and, when!= 0, callsgetUserLibraryIDsand threads the result into bothlistBooksandcountBooks— identical to the establishedbooks/handler.go ListHandlerpattern.Negative-scoping e2e test: GENUINE.
seedBookInInaccessibleLibraryinserts a library with NOuser_library_mappingrow for user 1, plus a book + metadata + author mapping.seedBooksForListmaps its own library to user 1, so the accessible book is real. The test asserts the HTML grid excludes "Inaccessible Book" and the heading showsBooks by Kafka, Franz (1)(not 2). Real positive-and-negative coverage on the HTML path.[MAJOR] internal/authors/show_handler.go:58 — JSON
GET /authors/{id}returns the UNSCOPED globalbook_countThe HTML path overrides
author.BookCountwith the scopedcountBooks(...), but the JSON branch (if handler.WantsJSON(r) { ... return json.NewEncoder(w).Encode(author) }) returnsauthorbefore any library scoping runs — soauthor.BookCountis still the global count fromGetAuthor(the unscopedGetAuthorByIDGROUP BY across all libraries). A JSON API client thus learns the total volume of an author's books including those in libraries it cannot access. This is the same class as the prior "book_count global" MAJOR — the fix wired the scoped count into the HTML path only. The unit test athandler_test.go:508("returns the book_count in JSON") actually pins the leak: it asserts the JSONbook_countequals the rawGetAuthorvalue (12), not the scoped count.Note this is a count-volume disclosure only — the JSON path does NOT return the book list, so no inaccessible-book identities leak (that was the BLOCKER and is fixed). Fix: move the
getUserLibraryIDsresolution above theWantsJSONbranch and applycountBookstoauthor.BookCountbefore encoding JSON (so both surfaces use the scoped count).[MINOR] internal/authors/service.go:~78 (buildListAuthorsQuery doc) — authors INDEX existence leak is a conscious deferral
GET /authorsstill lists ALL authors globally, including authors whose only books are in inaccessible libraries (author-existence + global per-author count leak). This is documented in thebuildListAuthorsQuerycomment as a tracked follow-up, with a sound rationale (LEFT-JOIN semantics change). Acceptable as a deferred follow-up; calling it out so the follow-up bead is not lost. Severity is existence/aggregate-count disclosure, not content disclosure.REVIEW VERDICT: 0 blocker, 1 major, 1 minor
030a10003728a5f7678828a5f7678852c5bd261dSecurity re-review — JSON-count MAJOR resolved
Focused re-review of the prior remaining
[MAJOR](JSON branch ofGET /authors/{id}returned the UNSCOPED globalbook_count). Verified againstgit diff origin/main...origin/bd-bookshelf-37ef.1(CI green @1a32a805).1. Scoped count now runs before content negotiation — RESOLVED.
In
internal/authors/show_handler.go,ShowHandlerresolvesuserLibraryIDsviagetUserLibraryIDs(fail-closed:userID==0leavesuserLibraryIDsnil only for the system path; an authenticated user with no mappings yields[]int64{}→CountAuthorBooksreturns 0) and then unconditionally overrides:Both the JSON branch and the HTML heading now emit the SCOPED count; the unscoped
book_countfromGetAuthorByIDis discarded.CountAuthorBooksbuilds itslibrary_id IN (...)clause with?placeholders only — no injection.2. Unit test updated — RESOLVED.
handler_test.goShowHandlerspecs set thegetstubBookCount:12(unscoped) butcountBooksstub7(scoped). Tests assert:decoded.BookCount == 7AND explicitlyNotTo(Equal(12)).ContainSubstring("7 books").This proves JSON and HTML agree on the scoped value and the unscoped value is never surfaced.
3. No regression on the book-list BLOCKER / negative-scoping e2e.
ListAuthorBooksstill applies the samelibrary_id IN (...)scoping (fail-closed empty →[]AuthorBook{}). The e2ebook in inaccessible library is excluded from grid and counttest seeds a book in an unmapped library and asserts the HTML grid excludes it AND the heading readsBooks by Kafka, Franz (1). Intact.4. Authors-index global-listing MINOR — still a documented deferral.
The scoping note in
buildListAuthorsQuery(service.go) remains, describing the existence leak as a tracked follow-up. Not regressed into a blocker.REVIEW VERDICT: 0 blocker, 0 major, 0 minor