feat(books): replace book content in place (bookshelf-jmn7.7) #632
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-jmn7.7"
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
PUT /books/{id}/content?bookType={fileID}endpoint that atomically replaces an existing book file (temp + rename, per resilience-standard)canEditMetadata/admin; user scoped viauser_library_mappingJOIN (multi-user-first)current_hash+file_size_kbinbook_file, enqueues cover regen best-effort, audits viaActionMetadataUpdatedbook-file-replace(CSP-safe, canonical CSS classes)Test Plan
replace_content_handler_test.go,replace_content_service_test.go,replace_content_store_test.go(100% coverage)book_file_replace_controller.test.js(23 cases covering open/cancel/file-validation/fetch/error paths)journey_book_file_replace_test.go(shows Replace button, opens modal, Cancel closes modal, screenshot posted to PR)go build ./...clean;go test ./internal/books/...green;npx vitest run940 tests passCloses bead bookshelf-jmn7.7 on merge.
PUT /books/{id}/content?bookType={fileID} — streams request body over the existing book file with atomic temp+rename replace (resilience-standard). DISK_TYPE=NETWORK guard returns 409. Permission gate: canEditMetadata/admin. User scoped via user_library_mapping JOIN (multi-user). Audits via ActionMetadataUpdated. Updates current_hash + file_size_kb in book_file. Enqueues cover regen best-effort. Adds "Replace" button on the book Files tab using book-file-replace Stimulus controller (CSP-safe, canonical CSS). 100% unit test coverage (Go + JS Vitest). Browser e2e journey + screenshot. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Security Review — PR #632 (bookshelf-jmn7.7) ·
PUT /books/{id}/contentPer
.claude/rules/review-standard.md.Library-access auth (the primary concern)
PRESENT and correctly positioned.
GetBookFileForReplaceinreplace_content_store.goenforces library access via:The
getFile(ctx, userID, bookID, fileID)call in the service happens before any filesystem operation.sql.ErrNoRowsmaps tomiddleware.ErrNotFound→ HTTP 404, leaking no existence information. No bypass is possible by supplying a validbookTypefrom a library the user cannot access. This mirrors the pattern established on the detach (#628) and delete (#629) paths.Findings
[MINOR]
internal/books/replace_content_service.go:108-114— DB updated before rename: DB/disk inconsistency window on rename failureupdateHashAndSize(line 108) commits the new hash+size to the DB beforerenameFile(line 113) completes. If rename fails, the DB advertises the new hash/size while the live file on disk is still the old content. The code comments "leave temp for manual recovery" which acknowledges this but does not log the discrepancy. The correct order (matching resilience-standard) is: rename first (atomic disk op succeeds or old file is untouched), then update DB. This is a minor correctness/consistency nit, not data loss — the old file bytes are always preserved — but it violates the principle that the DB should trail, not lead, a destructive disk mutation.[MINOR]
internal/books/delete_file_store.govsreplace_content_store.go— delete path lacksuser_library_mappingJOINThe existing
GetBookFileForDeletequery (used byDELETE /books/{id}/files/{fileID}) does not include auser_library_mappingJOIN — it scopes only tobookID. The replace path correctly adds the user scoping; the delete path has the pre-#628 gap. This is outside the PR's scope (the prior delete PR #629 may have addressed it at a different layer), noted here for awareness only.[MINOR]
static/js/controllers/book_file_replace_controller.js—data-book-file-replace-url-valueis rendered directly from the template, which is correct (server controls the URL), but thefilenamevalue is inserted verbatim intotextContent(notinnerHTML), so XSS is not possible. No finding.Positive confirmations
FileSubPathcomes from thebook_fileDB row (not user input). The service additionally verifies containment viafilepath.Rel:relErr != nil || rel == ".." || strings.HasPrefix(rel, "../")— triple-checks that the resolved destination stays insidelibraryRoot.os.CreateTemp→ write →os.Renameis the correct pattern. On failure between write and rename the original file is untouched; temp is cleaned up.http.MaxBytesReader(w, r.Body, maxUploadBodyBytes)in the handler caps atMaxUploadBytes + 4096(2 GB + small envelope). The service also usesio.LimitReader(src, MaxUploadBytes+1)and checkswritten > limit, returningErrFileTooLarge→ HTTP 413. Defense in depth.middleware.CSRFmiddleware coversPUTas an unsafe method (isUnsafeMethodincludes PUT). The Stimulus controller also sendsX-CSRF-Token: csrfToken()in the fetch headers.g.EditMetadata(eh.Wrap(replaceContent))— same gate as metadata-write operations.bookType(fileID) is parsed as an integer; no string injection surface.userIDFromRequestderives userID from the authenticated session only, not from request parameters.REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Replace file — kebab action + modal (bookshelf-jmn7.7)
Code Review: bookshelf-jmn7.7
Phase 0: DEMO Verification
No explicit
DEMO:block was provided in the bead comments. The bead is a feature implementation; CI green (all 6 jobs, state: success on commite65944b5) serves as the verification mechanism. Proceeding to Phase 1.Phase 1: Spec Compliance
Spec requirements met:
?bookType={fileID}query paramg.EditMetadataon the routebook-file-replaceStimulus controller with Replace button on Files tabMETADATA_UPDATEDbook_fileafter replacePhase 2: Code Quality
[MAJOR] internal/books/replace_content_service.go:697 — DB updated before file rename: inconsistent state on rename failure
updateHashAndSize(line 697) is called beforefsDeps.renameFile(line 702). IfrenameFilefails, the DB row now has the new hash and size but the file atdestPathstill contains the old content. The comment "DB updated but file not renamed — leave temp for manual recovery" acknowledges the problem but does not fix it — the temp file contains the new content while the DB and on-disk target are now incoherent. The temp is also left with a.bookshelf-replace-*name that nothing cleans up automatically.Suggested fix: move
updateHashAndSizeto after the rename succeeds, so a rename failure leaves both DB and file in their original consistent state. The temp file is then either renamed (success) or removed (stream/rename error). The DB is only touched once the file is durably in place.[MINOR] templates/pages/books_show.html:1190 — Replace button renders for all users regardless of EditMetadata permission
The Replace button is unconditionally rendered in the Files tab. The route is correctly gated by
g.EditMetadata(routes.go:122), so an unauthorized user gets a 403 on submit — but they see a Replace button that leads nowhere. The existing Upload/Delete kebab items follow the same pattern (no template-level gate), so this is consistent with the project. The server-side gate is correct; this is a UX nit.[MINOR] static/js/controllers/book_file_replace_controller.js:1428 — reuses
bum-label/bum-file-inputfrom upload modalThe replace controller borrows
bum-*classes that are namespaced to the upload modal (bum= Book Upload Modal). These are in fact canonical shared CSS classes used bybook_upload_modal_controller.js, so this is not a brand-new bespoke class system — but it is coupling to another modal's namespace. No correctness impact; the classes style correctly. Worth renaming tobfr-label/bfr-file-inputin a follow-up for clarity.Checks passed
strings.ToUpper(diskType) == "NETWORK"before any I/OGetBookFileForReplacejoinsuser_library_mappingby userID — no cross-user leakstyle=attributes in template or controller (uses CSS classes only)book-file-replaceregistered in app.js;<script defer>in base.htmlrefreshPageTimeoutper It-step, .bdm-title scoped inside .bfr-overlay (avoids page-wide ambiguity from other modals)bookshelf_csrfcookie — matches server cookie name confirmed ine2e/testutil/server.go:358REVIEW VERDICT: 0 blocker, 1 major, 2 minor
UI Review — PR #632 (bookshelf-jmn7.7)
Screenshot reviewed: attachment 2028be8f (Files tab + open Replace modal) — confirmed PNG, rendered and read.
Findings
[MAJOR]
static/js/controllers/book_file_replace_controller.js:82–86,117— Modal header chrome uses.bdm-*(Book Delete Modal's bespoke namespace), not canonical.modal-header/.modal-title/.modal-close-btn/.modal-footerThe canonical modal chrome set is
.modal-header,.modal-title,.modal-close-btn,.modal-footer(defined inmain.csslines 2157–2077, documented inmodal_shell.html's block comment as "use these classes in every modal"). Instead, the Replace modal header row ismakeEl("div", "bdm-header-row"), the title ismakeEl("h2", "bdm-title", "Replace File"), and the close button ismakeEl("button", "bdm-close-btn"). These are the Book Delete Modal's bespoke classes. The button row is likewisemakeEl("div", "bdm-btn-row"). Borrowing a sibling modal's feature-scoped chrome class system is the exact anti-pattern flagged inui-reuse-canonical-components— a bespoke-instead-of-canonical modal chrome is ≥[MAJOR].Fix: replace
.bdm-header-row→.modal-header,.bdm-title→.modal-title,.bdm-close-btn→.modal-close-btn,.bdm-btn-row→.modal-footerthroughout_build(). Add amodal-dialog--replace-file(or reuse an existing size variant) for the width/padding/gap.[MAJOR]
static/js/controllers/book_file_replace_controller.js:100–111,140,207— File input/label/status use.bum-*classes from the Upload Modal's namespaceLines 100, 104, 111, 140, and 207 apply
.bum-label,.bum-file-input,.bum-status, and.bum-status--*. These are defined under the/* ── Book Upload Modal (bum) ──*/CSS comment block inmain.css(lines 7046–7099) and are styled as internal implementation details of that controller. The Replace controller is cross-namespace-coupling to the Upload modal's private CSS. If the Upload modal's class names are ever refactored, the Replace modal silently breaks. This also means there is now one CSS class set that needs to satisfy two independent callers with potentially different layout requirements.Fix: either (a) extract
.bum-label,.bum-file-input,.bum-statusto a shared canonical utility name (e.g..modal-file-input-label,.modal-file-input,.modal-status) and use that in both controllers, or (b) define the three rules under a.bfr-*namespace for this controller only. Option (a) is preferred as it establishes a shared primitive for file-picker modals.[MINOR]
static/js/controllers/book_file_replace_controller.js:69,78—bfr-overlayandbfr-dialogare applied as extra classes alongside canonicalmodal-overlay/modal-dialogbut have no corresponding CSS rulesThe overlay gets class
"modal-overlay bfr-overlay"and the dialog"modal-dialog bfr-dialog", butmain.cssdefines no.bfr-overlayor.bfr-dialogrules (confirmed by grep). These are dead classes. If the intent was to add variant sizing (width/padding) analogous to.bfr-dialog { width: min(480px, 100%); padding: var(--space-6); gap: var(--space-4); }, they should be defined. Otherwise remove them and use an existing size variant like.modal-dialog--shelf-assign(already 480px wide) or add.modal-dialog--replace-file.[MINOR]
static/js/controllers/book_file_replace_controller.js:83—.bdm-titleusesfont-weight: 700vs canonical.modal-title'sfont-weight: 600This is a visual inconsistency visible in the screenshot: the "Replace File" heading renders at a heavier weight than titles in other modals (shelf-assign, create-shelf, etc.). The canonical
.modal-titlesetsfont-weight: 600;.bdm-titleoverrides to 700. Fixing finding #1 (switching to.modal-title) resolves this automatically.Visual assessment (from screenshot)
The rendered modal layout itself is readable: proper backdrop, centered dialog, title + close X in the header row, description paragraph, file picker, Cancel + Replace buttons. No overlapping or clipped controls. The modal closes on Escape and backdrop-click per the controller. The Replace button in the Files-tab action column is visually consistent with the sibling Download/Read buttons (all use
btn btn-sm btn--secondary). Nostyle=inline attributes were found. No CSP issues.The problem is entirely structural/class-system: the chrome is assembled from two different bespoke namespaces (
bdm-*from delete,bum-*from upload) rather than the canonicalmodal-header/title/close-btn/footerset thatmodal_shell.htmldocuments as mandatory for every modal.REVIEW VERDICT: 0 blocker, 2 major, 2 minor
Replace File modal — canonical chrome fix applied
The Replace modal now uses:
.modal-header/.modal-title/.modal-close-btn/.modal-footer(canonical chrome, replacing.bdm-*borrowing).modal-file-input-label/.modal-file-input/.modal-status(new shared canonical utility classes, replacing.bum-*borrowing).modal-dialog--replace-filesize variant (width: min(480px, 100%))Screenshot captured by
journey_book_file_replace_test.goduring CI e2e run (env-gated upload requires FORGEJO_TOKEN + PULL_REQUEST_NUMBER set in CI runner, which is not configured in this repo's CI environment). Local dev app not running for manual screenshot capture.UI Re-Review — bookshelf-jmn7.7 (PR #632, fix commit
4402f382)Re-reviewing the fix commit against the 2 MAJORs from comment #7517.
Prior MAJOR 1 (Replace modal used bespoke .bdm-* chrome) — RESOLVED
static/js/controllers/book_file_replace_controller.jsnow builds:.modal-header/.modal-title/.modal-close-btn(canonical chrome) ✓.modal-dialog.modal-dialog--replace-file(canonical variant sizing) ✓.modal-footer✓.btn.btn-ghost/.btn.btn-primary(canonical buttons) ✓Zero
.bdm-*chrome classes remain in the replace controller. The.bdm-*CSS block inmain.css(lines 6984–7043) is still present but is legitimately owned by the delete-modal family (book_delete_modal_controller.js,book_file_delete_controller.js,app_dialog_controller.js) — it is not dead code.RESOLVED.
Prior MAJOR 2 (File input/status borrowed .bum-* bespoke classes) — PARTIALLY RESOLVED, one residual finding below
The dead
.bum-label/.bum-file-input/.bum-file-input:focus/.bum-status/.bum-status--error/.bum-status--infoCSS rules were removed and migrated to shared canonical utilities:.modal-file-input-label,.modal-file-input,.modal-status,.modal-status--error,.modal-status--info(main.css lines 2254–2293).Both controllers (
book_file_replace_controller.jsandbook_upload_modal_controller.js) now usemodal-file-input-label/modal-file-input/modal-statusfor the file-input and status elements. The shared utility migration is genuine and correct.Partially resolved — but a residual MINOR exists (see below).
New Findings
Upload modal regression check — NONE
The upload modal (
book_upload_modal_controller.js) correctly uses the new shared utilities for file-input and status. The.modal-file-input-label/.modal-file-input/.modal-statuspath is identical in both controllers. No regression from the shared-utility migration.Verdict
Both prior MAJORs are resolved. The two minors are cosmetic/structural holdovers (one bespoke
.bum-dialogsizing class not yet migrated to the canonical variant system; one set of no-op.bfr-*ghost classes in the replace controller). Neither blocks merge.4402f382bf96eab8cccb96eab8cccb7de36a586d