feat(library): add book-folder paths in New Library modal (bookshelf-cojh) #582
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-cojh"
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
<input name="path">fields and submitted with the form.library_pathrows inside the same transaction as the library row + admin-mapping inserts.Implementation details
Createservice: new[]stringpaths parameter; validates each path lexically (validatePath), enforces max-500 count, then inserts rows viatxInsertPathinside the transaction.decodeLibraryBodyextended withpaths []stringfor JSON bodies;decodePathsBodyreads repeatedpathform fields for HTML; both merged increateHandler.library_modal.html):{{if .IsCreate}}block with#lem-create-path-list+createPathInputtarget,addCreatePath/removeCreatePathdata-actions.addCreatePathappends<li>with hidden<input name="path">+ remove button;removeCreatePathremoves the row. No server round-trip at this stage.PendingPathsfield onmodalDatare-renders existing paths on validation error.runInTxextended withtxQ.AddLibraryPath.Test plan
Createwith paths persistslibrary_pathrows; zero-paths works; invalid path returns ErrValidation; too many paths returns ErrValidation;insertPathfailure bubbles up.createHandlerform + JSON path submission passes paths toCreate; empty path strings are filtered.GET /libraries/{id}JSON); screenshot at/tmp/library_create_modal_with_path.png.Closes bead bookshelf-cojh on merge.
Screenshot: New Library modal with book-folder path added
The create modal now shows a "Book Folders" section. The user typed a path and clicked "Add Folder" — the path appears as a removable row with a hidden input that submits with the form.
Verified end-to-end by browser e2e:
GET /libraries/{id}after create confirmspathshas 1 entry with the expected path value.Security Review — bookshelf-cojh (PR #582)
Head SHA
403d8faa. CI green. Reviewed per.claude/rules/review-standard.md.Findings
[MAJOR] internal/library/service.go:193 —
Createskips thestat/IsDircheck thatAddPathenforcesAddPath(line 325–327) callsstat(path)and rejects anything that isn't a readable directory.Createonly callsvalidatePath()(lexical checks: empty, null bytes, length ≤ 4096, absolute, no..), so a caller can attach a path that does not exist or is not a directory at library-creation time. The immediate impact is modest (the scan will fail later when it tries to walk the path), but it's a behavioural divergence from the edit add-path flow: the same invariant is meant to apply at both entry points and this PR was specifically tasked to confirm parity. The lexical guard (validatePath) already eliminates traversal and injection; the missing piece is the existence/isDir check.Suggested fix: inject
stat func(string) (os.FileInfo, error)as a dep intoCreate(matching theAddPathsignature) and call it inside the path-validation loop before the transaction begins, returningmiddleware.ErrValidationon failure. The test suite already stubsinsertPathFn; a parallel stub forstatkeeps the unit tests free of real I/O.Confirmed clean
POST /librariesis behindg.ManipulateLibrary— unchanged.txInsertPathusessqlc.AddLibraryPathParamstyped params — fully parameterized.validatePathchecks empty, null bytes, length ≤ 4096, absolute,..segments — identical to the edit flow's lexical checks.maxCreatePaths = 500checked before any DB work.{{.}}insidevalue=andaria-label=rendered viahtml/template— auto-escaped.code.textContent = pathandhidden.value = pathare property assignments, notinnerHTML. Safe.library_pathtable /AddLibraryPathquery.REVIEW VERDICT: 0 blocker, 1 major, 0 minor
Security Re-Review — Pass 3 (SHA
60ab9641, PR #567, bead bookshelf-c15a.2)Prior findings — resolution status
[MAJOR] Cross-criterion cursor namespace collision — RESOLVED.
The new
ScanCursor.Positions map[string]CriterionPosgives each active criterion its own(afterKey, afterID). The handler readsuserLibraryIDsand buildsscopedIDsbefore the cursor is decoded, so the cursor has no influence on the library scope. Unknown keys inPositions(attacker-injected) are silently ignored via map-lookup — they cannot widen scope or cause a panic. TheafterKey/afterIDvalues are passed only as SQL parameters in a keyset WHERE clause; the library-ID filter (WHERE b.library_id IN (?)) is applied independently in every finder. Cross-user read via cursor is not possible.[MINOR] No
http.MaxBytesReader— RESOLVED.http.MaxBytesReader(w, r.Body, 4096)is applied inScanHandlerbeforeparseScanRequestis called (handler.go:128-130). The error-mapper handles*http.MaxBytesErrorviaerrors.As(checked beforeerrors.Is(ErrValidation)in the switch), so an oversized body returns 413 not 400. The handler unit test athandler_test.go:339-344confirms 413 is returned.Fresh threat-model
SQL injection — all five finder queries (
FindDuplicatesByISBN,FindDuplicatesByExternalID,FindDuplicatesByTitleAuthor,FindDuplicatesByDirectory,FindDuplicatesByFilename) pass every user-influenced value (cursorafterKey/afterID,limit+1) as positional?parameters. Library IDs are server-computed from the session and passed the same way. The LIKE patterns use%%/%%(Gofmt.Sprintfescaping to%/%) which are string literals, not user-controlled. No injection surface found.ExternalID UNION ALL arg binding — manually verified: HC branch gets
libIn, afterKey, afterKey, afterID, limit+1, libIn; GR branch gets the same pattern; total args =4N + 8matching the four%sexpansions + eight cursor/limit?s in the SQL. Both branches are independently scoped byuserLibraryIDs.Resource/DoS — request body capped at 4096 bytes via
MaxBytesReader. The cursor query param is bounded by Go's defaultMaxHeaderBytes(1 MB) and is a server-generated opaque blob with no unbounded sub-structure. Each finder's inner subquery limits tolimit+1groups (clamped to max 200 byclampLimit); the ExternalID combined outer query has no second LIMIT but is bounded by 2x(limit+1) groups, which is documented and handled correctly by the service-layer seen-map. No unbounded scan path found.Cursor decode safety —
DecodeCursorreturns a typed error (wrappingErrValidation) on invalid base64 or malformed JSON; no panic path. ThePositionsmap is deserialized into a Go struct with typed fields; oversized maps are bounded by the cursor string length, itself bounded by HTTP headers.Multi-user fail-closed — every finder has
if len(userLibraryIDs) == 0 { return nil, nil }as the first guard.filterToLibraryrejects requests for a library not in the user's accessible set (returns 404).userLibraryIDsis derived from the authenticated session, never from the request body or cursor.keptKeyspagination correctness — the seen2 loop correctly breaks onlen(seen2) > limit, excluding the peek group fromkeptKeys. ThelastPosupdate only iterateslabeledRowsinkeptKeys, so the cursor points to the last book of the last returned group, not the discarded peek group.New findings
No new blockers or majors. One minor style note:
[MINOR] internal/dedup/handler.go:53 — double-wrapped error format
%w: %wwith ErrValidation firstfmt.Errorf("dedup scan: decode body: %w: %w", middleware.ErrValidation, err)is an unusual multi-error pattern. ThestatusForswitch checkserrors.As(err, &maxBytesErr)beforeerrors.Is(ErrValidation), so the 413 path wins correctly and the test confirms it. The double-colon separator is cosmetic and non-standard but causes no correctness or security issue. The same pattern exists in the merge handler, suggesting it is an established codebase convention. No fix required.REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Code Review — bookshelf-q9sg (PR #548)
Phase 0: DEMO Verification
No DEMO block applies — this is a test-only coverage fix; the coverage gate is verified by CI (green).
Phase 1: Spec Compliance
Single file changed:
internal/stats/handler_internal_test.go. The bead requires a test exercising theday.After(today)break athandler.go:200-201. The diff delivers exactly that and nothing else. No scope creep.Phase 2: Code Quality
Weekday arithmetic verified independently:
time.Date(2024, 6, 12, ...)— 2024-06-12 is Wednesday (Gotime.Weekday()= 3, where Sunday=0).Cells[4]= Thursday 2024-06-13, which is aftertoday(2024-06-12).handler.go:200fires befored=4, leavingCells[4]nil.Expect(last.Cells[4]).To(BeNil())directly asserts this. Not a tautology.Determinism:
nowis a fixedtime.Date(2024, 6, 12, 12, 0, 0, 0, time.UTC)— notime.Now(). Coverage is date-independent; will not flake.Ginkgo conventions:
var (weeks []heatmapWeek)declared at top ofDescribeblock.JustBeforeEach, assertion inIt.ExpectperIt.nowdeclared insideJustBeforeEach(not a varying dep, noBeforeEachneeded). Acceptable.Coverage gate:
scripts/check-coverage.shunchanged. No exclusions added.No findings.
REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Code Review — bookshelf-cojh (PR #582)
Phase 0: DEMO Verification
No explicit DEMO block in the bead. The browser e2e test at
e2e/browser/library_modal_test.go:318(New Library modal: add book-folder path at creation) is the functional proof: it navigates to /books, opens the create modal, types a path, clicks Add Folder, submits the form, then verifies via the API that the library was created with the path without any edit step. CI is green. Screenshot posted as comment 6851. Phase 0 PASS.Phase 1: Spec Compliance
All five spec requirements are implemented:
Missing: the bead spec explicitly requires a JS unit test for add/remove rows in the create modal.
addCreatePathandremoveCreatePathwere added tolibrary_edit_modal_controller.jsbutstatic/js/test/library_edit_modal_controller.test.jswas not modified.Phase 2: Code Quality
[MAJOR] static/js/test/library_edit_modal_controller.test.js — JS unit tests for addCreatePath/removeCreatePath absent
The bead spec says "JS unit for add/remove rows in the create modal". The two new methods (addCreatePath, removeCreatePath) added to library_edit_modal_controller.js:120-163 are not covered by the existing JS test file, which was left unchanged (confirmed by git diff producing no output). The browser e2e catches end-to-end submission but DOM-manipulation bugs in the controller (wrong target selector, hidden-input name, li removal logic) would not surface in CI without a unit test. Every other Stimulus controller with non-trivial DOM manipulation has a matching .test.js. This is a spec gap and a convention violation.
[MINOR] e2e/browser/library_modal_test.go:362 — multiple Expect calls inside one It block
The single browser It block contains 11 Expect calls across two HTTP round-trips plus decoding. Project convention requires exactly one Expect per It. A follow-up split into focused It blocks (one for status, one for path count, one for path value) would be cleaner.
[MINOR] internal/library/handler.go:154-156 — form paths unconditionally decoded then overwritten for JSON
decodePathsBody(r) is called for every POST /libraries request including JSON, then immediately discarded for JSON (it returns nil due to its own Content-Type guard). No bug, but confusing: paths = decodePathsBody(r) always runs, then if len(jsonPaths) > 0 { paths = jsonPaths } silently overlays it. An explicit else-branch makes the mutual-exclusion clear.
REVIEW VERDICT: 0 blocker, 1 major, 2 minor
403d8faa48a79ef4de13Review findings addressed in commit
2bf8216eeb:[MAJOR] security — Create stat/IsDir parity with AddPath
internal/library/service.go:Createnow acceptsstat func(string) (os.FileInfo, error)as first curried dep; iterates supplied paths and calls stat after lexical validation, rejecting non-existent or non-directory paths with the same error asAddPath(line ~210-214).internal/library/wire.go: wired withos.Stat.internal/library/service_test.go: two newContextblocks — non-existent path →ErrValidation, regular-file path →ErrValidation; all existing callers updated to new 3-arg signature.[MAJOR] code — JS unit tests for addCreatePath / removeCreatePath
static/js/test/library_edit_modal_controller.test.js: addedmountCreateModalhelper and 5 new tests inADD CREATE PATHdescribe block:input[type=hidden][name=path].lem-path-textliappended)removeCreatePathremoves the correctliviaclosest('li')[MINOR] e2e one-Expect per It
e2e/browser/library_modal_test.go: split the singleItwith 11 Expects into aDescribewith sharedBeforeEach(browser interaction + API round-trips) and 3 separateItblocks: library appears in list, exactly one path, correct path value.[MINOR] handler readability
internal/library/handler.go:154-156: restructured toif len(jsonPaths) > 0 { paths = jsonPaths } else { paths = decodePathsBody(r) }— no unconditional call followed by overwrite.Security Re-Review — bookshelf-cojh (PR #582, SHA
2bf8216e)Scope: re-verify the [MAJOR] from comment 6854 (Create lacked stat/IsDir check); confirm no remaining create-vs-edit validation divergence; audit all other surfaces.
Primary finding (comment 6854) — RESOLVED
[MAJOR] Create skipped the stat/IsDir check — FIXED.
internal/library/service.goCreatenow:stat func(string) (os.FileInfo, error)as its first curried argument (line ~174).validatePath(p)(null bytes / length / absolute / no-..segments) thenstat(p)and checks!info.IsDir()— identical logic toAddPath(lines ~200-207).middleware.ErrValidationon any failure, same error type asAddPath.internal/library/wire.gopassesos.Statat construction:Create(os.Stat, runInTx, ...)— parity withAddPath(os.Stat, ...).Create-vs-AddPath divergence: none remaining.
validatePath(null / length / absolute / no-..)statexistence +IsDirRemaining surfaces — all clear
Parameterized
library_pathinsert:txInsertPathreceivessqlc.AddLibraryPathParamswith typedsql.NullString— no string interpolation into SQL.validatePath(null bytes / length / absolute / no-..): called on every path in bothCreateandAddPath. The.segment is not explicitly rejected, butfilepath.IsAbs+".."segment check covers the traversal cases; a bare.in a path component doesn't escape an absolute root prefix and is harmless.Count bound:
maxCreatePaths = maxLibraryPaths = 500enforced before the transaction, before stat calls — correct ordering prevents a slow-path DoS on an oversized batch.Auth/ownership: unchanged —
d.AdminRequired/ManipulateLibrarygate remain on the create route.XSS —
PendingPathstemplate rendering: all three emission points intemplates/pages/library_modal.html(lines ~76–78) use{{.}}insidehtml/template— Go'shtml/templateauto-escapes for HTML context (<code>), attribute context (value="{{.}}",aria-label="Remove path {{.}}"). No unescaped{{- . -}}ortemplate.HTMLcasts. Safe.No new Grimmory columns: diff touches only Go service/handler/wire/test/JS/template files — no
.sqlmigration orsqlcqueries modified.Minor observations (no security impact)
[MINOR]
internal/library/handler.go:155 —PendingPathsonly set on form pathOn a JSON
POST /librariesthat fails validation,PendingPathsis populated correctly (it is set regardless of content-type). No issue; just noting the comment indecodeLibraryBodyis accurate. No action needed.[MINOR]
internal/library/handler.go:154-158 —decodePathsBodyreturnsnilfor JSON, thenjsonPathsbranch is takenThe
if len(jsonPaths) > 0 { paths = jsonPaths } else { paths = decodePathsBody(r) }correctly handles both content types. The else branch correctly returns nil for a JSON body (guarded insidedecodePathsBody). Logic is sound.REVIEW VERDICT: 0 blocker, 0 major, 2 minor
CODE REVIEW (RE-REVIEW): APPROVED
Re-review of #567 (bd-bookshelf-c15a.2) against prior findings in comment 6689.
Prior Finding Verification
MAJOR 1: ExternalID OR-dup loss — RESOLVED
The fix rewrites FindDuplicatesByExternalID using two independent EQUI-JOIN subqueries (hardcover and goodreads) merged with UNION ALL (store.go:278-381). Each provider query uses an equi-join with no OR. A book with both hardcover_id and goodreads_id appears exactly once per provider group.
The service.go seen-map key is now a (bookID, groupKey) composite (service.go:92) — so book A can appear in both hc:HC001 and gr:GR001 groups without being dropped. The e2e test seeds exactly the A+B (hc) / A+C (gr) scenario and asserts HaveLen(2). Finding is resolved.
MAJOR 2: Directory/Filename correlated-subquery — RESOLVED
Both FindDuplicatesByDirectory and FindDuplicatesByFilename now use derived-table EQUI-JOINs with no per-row correlated subquery (store.go:453-493 for directory, store.go:551-595 for filename). Migration 0037 adds idx_book_file_dedup_dir (book_id, is_book, file_sub_path) on book_file. All ORDER BY clauses use a two-column total order with unique tiebreaker. Finding is resolved.
MAJOR 3: Positive e2e tests for same_directory, filename, both-ids external_id — RESOLVED
All three are positive tests. Finding is resolved.
MINOR: Browser checkbox MustEval bool read — RESOLVED
e2e/browser/find_duplicates_test.go now uses titleAuthorCB.MustEval("() => this.checked").Bool(). Finding is resolved.
MINOR: Stray double blank line — RESOLVED
The extra blank line after the import block is gone. Finding is resolved.
Fresh-Eyes Pass
Multi-user/library scoping: Both new directory and filename finders filter by userLibraryIDs in the inner grp subquery AND the outer WHERE clause. Empty-slice fail-closed guard present. Correct.
Scale: No N+1. All new queries use derived-table joins. ORDER BY uses total-order two-column keys. LIMIT bound on all queries. Covering index on book_file (migration 0037). Clean.
Grimmory compat: Migrations 0036 and 0037 add indexes only, no new columns. Correct.
CSP: No inline style= in templates. CSS classes used throughout. Clean.
[MINOR] e2e/browser/find_duplicates_test.go:494 — single It block contains multiple Expect assertions
The browser spec chains four independent assertions (balanced chip active, strict chip active, titleAuthor unchecked, badge text). Project convention requires one Expect per It. Split into four It blocks. Does not block merge.
REVIEW VERDICT: 0 blocker, 0 major, 1 minor
CODE RE-REVIEW: APPROVED — bookshelf-cojh (PR #582, SHA
2bf8216e)Fix Verification
[MAJOR code] JS unit tests for addCreatePath/removeCreatePath
VERIFIED. static/js/test/library_edit_modal_controller.test.js now contains a full describe("ADD CREATE PATH") block with 5 non-vacuous tests:
All tests exercise real controller logic via the Stimulus test harness. Non-vacuous and complete.
[MAJOR security] service.go Create stat-validates each path before tx
VERIFIED. internal/library/service.go Create now accepts stat func(string) (os.FileInfo, error) as its first dependency (line 173), calls validatePath(p) then stat(p) for each supplied path before opening the transaction, and wraps any stat error or non-directory result in middleware.ErrValidation — exact parity with AddPath.
internal/library/wire.go passes os.Stat at line 49.
Unit tests in service_test.go cover:
[MINOR] e2e 11-Expect It split
VERIFIED. The new e2e Describe uses a shared BeforeEach (all setup + HTTP calls) with exactly 3 It blocks, each containing exactly one Expect.
[MINOR] handler.go decodePathsBody if/else
VERIFIED. createHandler at lines 154-158 uses a clean if/else branch. decodePathsBody also uses an early-return guard for JSON content type.
Additional Observation
[MINOR] e2e/browser/library_modal_test.go — nil-panic risk in third It block
The It("persists the correct path value") block indexes libPaths[0] unconditionally. libPaths is only populated inside "if newLibID != 0" in BeforeEach. If the library was not found (newLibID stays 0), libPaths remains nil and libPaths[0] panics rather than producing a clean Ginkgo failure. Ginkgo runs It blocks independently, so the third can execute and panic even when the second already failed. In a green-path e2e this never fires. Minor, does not block.
Compatibility checks
REVIEW VERDICT: 0 blocker, 0 major, 1 minor