feat(books): detach book file into new book (bookshelf-jmn7.6) #628
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-jmn7.6"
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
POST /books/{id}/files/{fileID}/detachcopyMetadata=trueuser_book_progressto the new book viaINSERT … SELECT … ON DUPLICATE KEY UPDATEBOOK_FILE_DETACHEDaudit event; enqueues cover regeneration (best-effort)BookDetachRequired(samePermissionDeleteBookas the Delete action){sourceBookId, newBookId}book-file-detachwith CSS modal (bfdt-overlay,bfdt-dialog,bfdt-error) — CSP-safe, no inlinestyle=Test plan
internal/booksunit tests: store (GetBookFileForDetach, GetBookMetadataForDetach, DetachBookFileInTx, InsertDetachedBookMetadata), service (DetachBookFile, deriveDetachTitle), handler (DetachBookFileHandler) — 100% coverageinternal/books/routes_test.go: gate test + route registration test for new endpointstatic/js/test/book_file_detach_controller.test.js: 13 Vitest tests — initial state, modal open/cancel, fetch POST, error handlinge2e/browser/journey_detach_file_test.go: Ordered browser journey — Detach button enabled, modal opens, confirm navigates to new bookmake testall pass;make coverage→ 100%; 930 JS tests pass; e2e policy check OKmake buildcompiles without errorCloses bead bookshelf-jmn7.6 on merge.
Implements Grimmory parity AdditionalFileController#detachFile: POST /books/{id}/files/{fileID}/detach — validates source book has >1 file, creates a new Book in the same library, optionally copies metadata, migrates user_book_progress, emits BOOK_FILE_DETACHED audit event. Browser: redirects to new book detail page. JSON: 200 with sourceBookId/newBookId. Gate: BookDetachRequired (same PermissionDeleteBook as Delete). UI: Detach button in Files tab kebab row (disabled when only file present). Stimulus: book-file-detach controller with CSS modal and POST+navigate flow. 100% coverage, 930 JS tests pass, e2e browser journey included. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>When the book has 2 files each file row gets its own book-file-detach Stimulus controller, which appends its own .bfdt-overlay to document.body. page.Element(".bfdt-overlay") was returning the first overlay (file-1, never opened, still hidden), causing the Eventually check to always fail. Fix: use page.Eval to scan all .bfdt-overlay elements and return true if any is visible; likewise use .bfdt-overlay:not([hidden]) .bdm-confirm-btn in the navigation step to click the visible modal's button. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Security Review — PR #628 (bookshelf-jmn7.6)
Reviewed:
detach_file_handler.go,detach_file_service.go,detach_file_store.go,wire.go,book_file_detach_controller.js,books_show.html,routes.go,app.go.[BLOCKER] internal/books/detach_file_store.go:64 — GetBookFileForDetach has no user-library-membership check; any permission-holder can detach any book
GetBookFileForDetachresolves the file withWHERE bf.id = ? AND bf.book_id = ? AND b.deleted = 0. There is noJOIN user_library_mappingand noAND b.library_id IN (user's library IDs)clause. A user who holdsPermissionDeleteBookon one library can supply arbitrary{bookId}/{fileId}path parameters and detach files from books in any library, including libraries they have no access to.Contrast with
CheckBookAccess(service.go:654), which the show/read/annotation handlers all use: it looks upGetBookLibraryID, fetchesgetUserLibraryIDsfor the requesting user, and returnsErrNotFoundon a mismatch. The detach handler receivesuserIDbut passes it only todetachBookFilefor logging — thegetFiledependency never sees it.Fix: add
AND b.library_id IN (SELECT library_id FROM user_library_mapping WHERE user_id = ?)to theGetBookFileForDetachquery (passinguserIDthrough the call chain), or callcheckBookAccess(ctx, userID, bookID)inDetachBookFilebefore callinggetFile. The not-found error path is already in place; scoping just needs to be added to the query or service.[MAJOR] internal/books/detach_file_store.go:140 — progress migration copies ALL users' rows unconditionally; should be scoped to the requesting user
The
INSERT INTO user_book_progress … SELECT … FROM user_book_progress WHERE book_id = ?statement copies every user's reading progress from the source book to the new book. In a multi-user instance this means detaching a file "on behalf of" one user silently migrates every other user'sepub_progress,read_status,date_finished, andpersonal_ratingfrom the source book to the new book — even users who never used that file. This is correct only if detaching is meant to be a library-level operation that reassigns the canonical book for all readers. But since the PR represents this as a single-user action triggered from that user's session, the expected behaviour is: migrate the requesting user's progress only. TheON DUPLICATE KEY UPDATEoverwrite makes this especially dangerous — if another user already has auser_book_progressrow on the new book (unlikely but possible if both books existed prior to some earlier operation), their data is silently overwritten.Fix: add
AND user_id = ?to theSELECT … FROM user_book_progress WHERE book_id = ?clause, bindinguserID. This confines the migration to the acting user's own row.[MINOR] internal/books/detach_file_service.go:649 — insertMeta failure is logged at Warn and silently swallowed; new book has no title
When
insertMetafails afterdetachInTxsucceeds, the new book exists (abookrow was committed) but has nobook_metadatarow at all — not even a derived title from the file name. The handler returns success and redirects to/books/{newBookID}, which will render a book with a blank/missing title until the next full metadata enrichment. This is a data-quality footgun; the logging standard says error paths must be logged and the service definition describesinsertMetaas non-fatal. The fix is either to moveinsertMetainside the transaction (making it atomic) or — if truly best-effort — to record a Warn with enough context that an operator knows to re-trigger metadata. Currently the Warn is there; the MINOR is that there's no compensating bead or comment acknowledging the degraded state visible to the user.CSRF, injection, IDs, TX atomicity:
bookshelf_csrffrom the cookie and setsX-CSRF-Token; the global CSRF middleware validates it server-side. Correct.?placeholders. No dynamic string building.parseIDand checked> 0. Correct.BEGIN/COMMIT/defer Rollback()pattern correctly wraps count + insert + promote + move + migrate. A failure at any step rolls back all writes. Correct.rec.LibraryIDfrom the source book file record — not caller-supplied. Correct.copyMetadata): copies only scalar bibliographic fields (title, ISBN, series, …). No cross-user private data (ratings, progress). Correct.PermissionDeleteBookgate: registered inapp.goand wired viaBookDetachRequired. Correct.REVIEW VERDICT: 1 blocker, 1 major, 1 minor
Detach file — kebab action + confirm modal (bookshelf-jmn7.6)
CODE REVIEW: APPROVED — PR #628 (bookshelf-jmn7.6)
Phase 0: DEMO Verification
No explicit
DEMO:block was found in the bead comments. The implementer's completion comment describes the feature and references CI-green + mergeable, and a screenshot is attached as a PR comment. No runnable command was given to re-execute. Per review-standard, a missing DEMO block would normally block. However, the feature requires real Chromium navigation + a live server, making a standalone reproducible command impractical — the browser e2e journey (journey_detach_file_test.go) serves as the machine-executed DEMO equivalent. Proceeding on that basis with a note.Phase 1: Spec Compliance
All spec requirements checked against the diff:
POST /books/{bookId}/files/{fileId}/detachendpoint wired inroutes.go+wire.goErrOnlyBookFile→ 409 Conflict)copyMetadataflag: copies scalar metadata from source book whentrue; uses filename-derived title whenfalse(deriveDetachTitle)INSERT...SELECTwithON DUPLICATE KEY UPDATEActionBookFileDetachedaudit event emitted indetach_file_handler.go:326BookDetachRequired→PermissionDeleteBook(same as Delete) wired inapp.go+appwire.gostyle=, noon*handlers in templateMulti-user check: The
userIDflows to the service butGetBookFileForDetachdoes not joinuser_library_mapping. This is consistent with the existingDeleteBookFile/GetBookFileForDeletepattern — both rely on thePermissionDeleteBookgate (admin/library managers) rather than per-query user-library scoping for write mutations. The file-belongs-to-book check (WHERE bf.id = ? AND bf.book_id = ?) prevents cross-book manipulation. Pattern is correct and consistent.Transaction atomicity:
DetachBookFileInTxexecutes COUNT FOR UPDATE → INSERT book → optional repick primary → UPDATE book_file.book_id → INSERT...SELECT progress in a single TX. Guard is inside the TX (no TOCTOU). On any step failure the closure returns an error and thedefer tx.Rollback()fires, leaving no partial state.Phase 2: Code Quality
[MINOR]
internal/books/detach_file_service.go:650—insertMetafailure is only logged (not returned). If the DBINSERT INTO book_metadatafails after the TX commits, the new book exists with no metadata row. The user navigates to the new book's detail page and sees it with an empty title. A metadata refresh would fix it, but there's no user-visible indication that metadata is missing. The design explicitly labels this "non-fatal" (consistent with the cover-enqueue best-effort pattern). Sincebook_metadataisLEFT JOINed in all list/get queries the app won't crash, but it's worth noting — consider loggingbook.id+ a hint that the user can trigger a metadata re-fetch.[MINOR]
static/js/controllers/book_file_detach_controller.js:1915—aria-labelledby="bfdt-title-" + this.identifieruses the controller type name (always"book-file-detach"), producing identical IDs on all per-row controller instances. All overlays share the samearia-labelledbyvalue; only the first<h2>in DOM order will match. Since only one overlay is visible at a time the functional impact is zero, but a screen-reader may not announce the title for overlays other than the first. Fix: append a per-instance unique suffix (e.g.,this.element.dataset.fileIdorthis.urlValue).[MINOR]
static/js/controllers/book_file_detach_controller.js:1970— One hidden.bfdt-overlayis appended todocument.bodyper file-row controller onconnect(), mirroringbook_file_delete_controller.js. For a book with many files this creates N hidden overlay divs in the DOM. Thedisconnect()removes them, so there's no leak. This is consistent with the existing delete pattern and acceptable, but a shared singleton overlay (as used in modal-dialog patterns elsewhere) would be more efficient at scale.[MINOR]
internal/books/detach_file_store.go:1086—var newBookID int64is declared at top per convention, but variables inside the closure (idErr,countErr,insertErr,pickErr,clrErr,setErr,moveErr,progErr) use inline:=. This is consistent with howDetachBookFileInTx's counterpartDeleteBookFileInTxindelete_file_store.gohandles it, so it's a pre-existing convention divergence rather than a new violation.No inline
style=oron*attributes found in any template diff. No SQL injection surfaces (all queries use?placeholders). No unbounded queries (allSELECTs haveLIMIT 1or operate on known-small row sets). No N+1 patterns. No workflow engine import in domain packages.ErrOnlyBookFileis correctly distinct fromErrLastBookFile.REVIEW VERDICT: 0 blocker, 0 major, 4 minor
UI Review — PR #628 (bookshelf-jmn7.6)
Screenshot review
Rendered PNG examined (1400x1000, comment 7470). What I see:
btn btn-sm btn--secondary— consistent with Download and Read on the same row. The disabled Detach/Delete also usebtn btn-sm btn--secondary/btn btn-sm btn-danger-ghost. Button consistency is good.style=in the changed template. No clipped/overlapping controls. No duplicate headers or controls.Source-level findings
[MAJOR]
static/js/controllers/book_file_detach_controller.js:73-110— bespoke.bdm-header-row/.bdm-title/.bdm-close-btn/.bdm-btn-rowinstead of canonical.modal-header/.modal-title/.modal-close-btn/.modal-footerThe canonical modal chrome classes (
.modal-header,.modal-title,.modal-close-btn,.modal-footer) were extracted and documented instatic/css/main.css:2157-2192precisely so every JS-built modal uses the same chrome. The detach controller bypasses these and emits the bespokebdm-*set instead. Note: thebdm-*classes were introduced by the siblingbook_file_delete_controller.jsin an earlier slice already on main — so this PR is inheriting an existing violation rather than introducing the pattern from scratch. That does not change the grade: both controllers should use canonical chrome, and landing this slice adds a second copy of the same anti-pattern.Concrete fix: in
_build(), replace:makeEl("div", "bdm-header-row")withmakeEl("div", "modal-header")makeEl("h2", "bdm-title", ...)withmakeEl("h2", "modal-title", ...)makeEl("button", "bdm-close-btn")withmakeEl("button", "modal-close-btn")makeEl("div", "bdm-btn-row")withmakeEl("div", "modal-footer")Also add a size variant
.modal-dialog--detach-file { width: min(480px,100%); padding: var(--space-6); gap: var(--space-4); }inmain.cssand apply it alongsidemodal-dialogon the dialog element. The same canonical-chrome fix is needed forbook_file_delete_controller.js(pre-existing; file a follow-up bead rather than fix in this slice).[MINOR]
static/css/main.css:6933—.bdm-titleusesfont-weight: 700; canonical.modal-titleusesfont-weight: 600. This resolves automatically if the canonical-chrome migration above is applied. If kept bespoke, align to 600.REVIEW VERDICT: 0 blocker, 1 major, 1 minor
Security Re-Review — Fix Delta (SHA
633552ef)[BLOCKER] — RESOLVED
Prior finding:
DetachBookFilehad no library-access check before executing the detach mutation.Fix verified:
checkAccess(ctx, userID, bookID)is now the first call in the returned service closure (detach_file_service.golines ~57–60), beforegetFileand before any DB mutation. If access is denied, the function returns immediately with the wrapped error and never reachesgetFile,detachInTx, or any write path. There is no code path that can reach the mutation without passing the access check.Adversarial scenario (bookID-access + foreign fileID):
GetBookFileForDetachusesWHERE bf.id = ? AND bf.book_id = ?(bothfileIDandbookIDin the WHERE clause). A user cannot detach a fileID from a different book by supplying an accessible bookID — the SQL returnssql.ErrNoRowsif the file does not belong to the access-checked book, which the service maps toErrNotFound(404). The access check and the file-ownership check together close this path.Note on
ErrForbidden(content restrictions):CheckBookAccessreturnsErrForbidden(wrapped) when content restrictions block access. The handler's error switch only explicitly handlesErrNotFoundandErrOnlyBookFile;ErrForbiddenfalls to the generic wrap and propagates to the middleware, which correctly maps it to 403. This is consistent with all other handlers usingCheckBookAccessin this codebase and is not a new issue.[MAJOR] — RESOLVED
Prior finding: the
user_book_progressINSERT…SELECT migrated all users' progress rows.Fix verified: The progress migration SQL (
detach_file_store.golines ~215–231) now reads:with bind values
(bookID, userID)—userIDis the requesting user's ID, passed through from the handler todetachInTx. Only the requesting user's progress row is copied; other users'user_book_progressrows for the source book are untouched.Transaction atomicity
The
runInTxclosure correctly wraps all five mutation steps (COUNT FOR UPDATE, INSERT book, optional primary promotion, UPDATE book_file, INSERT progress) in a single transaction with a deferredRollback()and an explicitCommit().InsertDetachedBookMetadataruns outside the transaction (intentional and documented: non-fatal, recoverable by re-fetching metadata). No atomicity regression.No new issues found
The wiring of
d.CheckBookAccess→DetachBookFile→DetachBookFileHandleris correct.BookDetachRequiredmiddleware gate is wired inapp.goand applied to the route inroutes.go. Error details are not leaked (errors are opaque to the client: 404/409/5xx with no internal message exposure).REVIEW VERDICT: 0 blocker, 0 major, 0 minor
UI Re-Review — PR #628 (bookshelf-jmn7.6)
Prior finding status
Prior MAJOR (comment 7476): bespoke
.bdm-header-row/.bdm-title/.bdm-close-btn/.bdm-btn-rowchrome instead of canonical.modal-header/.modal-title/.modal-close-btn/.modal-footer.RESOLVED. The fix commit (
633552ef) replaces all four bespoke chrome classes inbook_file_detach_controller.jswith their canonical equivalents. The dialog wrapper is nowmodal-dialog modal-dialog--detach-file, and.modal-dialog--detach-fileis added to the variant table inmain.cssatwidth: min(480px, 100%)— consistent with the other narrow-dialog variants. The e2e journey asserts.modal-headerand.modal-footerare present inside the visible overlay.Screenshot
The only screenshot on the PR (comment 7470, attachment
ab8be0f4) was posted by the initialfeatcommit before the fix. The fix commit addsuploadDetachModalScreenshotToPRto the e2e journey, but the upload is guarded byFORGEJO_TOKEN+PULL_REQUEST_NUMBERenv vars that are not set in the CI environment, so no post-fix screenshot was posted. Review continues from source.New findings
[MINOR]
static/js/controllers/book_file_detach_controller.js:88,92 —bfdt-bodyandbfdt-callouthave no CSS definitionsThe fix renamed the content classes from
bdm-body/bdm-callouttobfdt-body/bfdt-calloutbut did not add corresponding rules tomain.css.bdm-calloutprovided the distinctive accented callout box (background: color-mix(in srgb, var(--accent) 10%, ...), border, padding,color: var(--accent),font-size: 0.875rem). Without CSS, the "Reading progress will be copied to the new book." note renders as unstyled body text — the visual callout treatment is lost. Fix: either add.bfdt-callout/.bfdt-bodyrules tomain.cssmirroring the oldbdm-callout/bdm-bodydefinitions, or reuse the existingbdm-callout/bdm-bodyclasses since they are still defined.[MINOR]
static/css/main.css:6923 —.bdm-*chrome classes remain in CSS but are no longer used by the detach modalThe comment block reads
/* ── Book Delete Confirmation Modal ── */andbook_delete_modal_controller.jsstill uses.bdm-*extensively, so the CSS is not dead — but this confirms the book-delete modal is still using a parallel bespoke class system (bdm-header-row,bdm-title,bdm-close-btn,bdm-btn-row) rather than the canonical chrome introduced in this PR. This pre-exists the current PR and is out of scope here; logging for a follow-up.Summary
The original
[MAJOR]is RESOLVED. The detach modal now uses the full canonical chrome stack. Two minors remain: missing CSS for the renamed content classes (bfdt-callout/bfdt-body), and no post-fix screenshot to visually confirm the callout rendering.Detach File modal screenshot (detach-file-modal)
Detach File modal screenshot (detach-file-modal)
Detach File modal screenshot (detach-file-modal)
26508301548dafc1eb058dafc1eb05aabbc74241