feat(bookdrop): capture embedded file metadata into OriginalMetadata at ingest (bookshelf-sp5u) #354
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-sp5u"
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
Resolves the TODO at
internal/bookdrop/service.go:119—OriginalMetadatawas always stored as null, so the bookdrop review UI had no way to show what a file itself claims vs fetched metadata.Placement decision: extraction at ingest time (detection step)
The extraction is placed in
IngestFile(the detection/watcher step), not deferred to the accept/review flow. Rationale:scan.ExtractMetadatafor EPUB opens only the small OPF XML entry inside the zip usingio.ReaderAt(seek-based, no full-file buffering). For non-EPUB formats it falls back to filename parsing — zero I/O.bookFormatsmap), so no non-book files are processed.Changes
internal/bookdrop/meta.go(new):ExtractFileMetadata(absPath, logger)— wrapsscan.ExtractMetadataviaos.DirFS, serialises{title, authors}as JSON into asql.NullString.internal/bookdrop/service.go:IngestFilegains anextractMeta func(string) sql.NullStringdep (curried pattern); populatesOriginalMetadatafrom it.internal/app/app.go+internal/app/build_extended_deps.go: bothIngestFilewiring sites updated to passbookdrop.ExtractFileMetadata.internal/bookdrop/meta_test.go(new): tests for EPUB with embedded title+author, graceful null on missing file.internal/bookdrop/service_test.go: existing tests updated for new dep; new contexts assertOriginalMetadatais populated and that null extraction still ingests cleanly.Test plan
go build ./...cleanmake lintno new issues in bookdropgo test ./internal/bookdrop/...passscripts/check-coverage.sh100% (coverage gate passes)Closes bead bookshelf-sp5u on merge.
[MINOR] internal/bookdrop/meta.go —
ExtractFileMetadatacalled synchronously on every detected file; 256 MB fallback buffer reachable in tests but not in production (os.DirFS files expose io.ReaderAt, so production always uses the zero-copy path; the 256 MB limit only fires in test or non-os-FS contexts).Extraction failure is non-fatal: error is logged at Warn level and a null NullString is returned; the ingest continues normally. Confirmed.
No path traversal:
ExtractFileMetadataconstructsos.DirFS(filepath.Dir(absPath))and opens onlyfilepath.Base(absPath)— the scan can never escape the dropped file's own directory.No XXE: Go's
encoding/xmldecoder does not expand external entity references (DTD processing is not supported); no XXE risk.XML decompression-bomb protection:
decodeXMLEntrywraps every XML read inio.LimitReader(rc, maxXMLBytes)(1 MB). Both container.xml and the OPF are capped before decoding.ZIP decompression-bomb protection: on the preferred code path (
os.File→io.ReaderAt)zip.NewReaderopens the central directory without reading compressed data; individual entries are only opened for XML, not for content/images. No uncontrolled memory allocation.Author-flood protection:
maxAuthors = 20caps the authors slice before any DB round-trips.No panic path: all errors from the parsing stack return
(BookMetadata{}, nil)for parse failures and(BookMetadata{}, error)for I/O failures; both are caught inExtractFileMetadataand degrade gracefully.JSON storage safety:
json.Marshalon a struct with onlystring/[]stringfields cannot produce injection artefacts.OriginalMetadatais not rendered in any template or API endpoint in the current diff — there is no XSS surface in this PR.[MINOR] internal/files/cover_extract.go:507 —
slog.Default()used insidedecodeXMLEntry(pre-existing, not introduced by this PR). This violates the project's "inject logger, never use global" convention. Not a security issue but worth noting; pre-existing MINOR.REVIEW VERDICT: 0 blocker, 0 major, 2 minor
CODE REVIEW: APPROVED - minor issue only
Phase 0: DEMO Verification
No DEMO block in this bead (metadata extraction at ingest is not user-visible in isolation; the stored value is verified via unit tests). Proceeding on the basis that CI is green.
Phase 1: Spec Compliance
extractMeta(absPath)inIngestFile— confirmed ininternal/bookdrop/service.go:122.scan.ExtractMetadatais reused (no new parser) — confirmed ininternal/bookdrop/meta.go.logger.Warn(...)+ returnssql.NullString{}so ingest continues — confirmed ininternal/bookdrop/meta.go:29-32.internal/app/app.go:514andinternal/app/build_extended_deps.go:351.extractMeta func(string) sql.NullString— follows project convention.Phase 2: Code Quality
Serialization correctness
The JSON shape is
{"title":"...","authors":[...]}withomitempty. Fields are omitted (not null/empty-stringed) when absent — correct and compact.json.Marshalsuppression (b, _ := json.Marshal(payload)) is safe: the comment is accurate, a struct ofstring/[]stringcannot produce a marshal error.The column has
CHECK (json_valid(...))— the produced JSON is always valid.StatusFailedalso writes JSON{"error":"..."}to the same column viaUpdateBookdropFileWithError; that path is not yet wired and overwrites whatever was stored at ingest. This is pre-existing design, not introduced by this PR.I/O cost at ingest
For non-EPUB formats (PDF, CBZ, CBR, MOBI, M4B, MP3),
scan.ExtractMetadataonly callsParseFilenameMetadata— no file open, no I/O beyond whatIngestFilealready does.os.DirFS(...)creates a lazy FS view with no syscall. EPUB-only ZIP parsing is the only added I/O and that is bounded to a single file per ingest event. Acceptable for a watch-folder.Extraction called for duplicate files
Extraction happens inside the
insert(ctx, sqlc.InsertBookdropFileIfNewParams{..., OriginalMetadata: extractMeta(absPath)})call — Go evaluates all arguments before the call, soextractMetaruns even when theON DUPLICATE KEY UPDATEmakes the insert a no-op (file already exists in DB). For EPUB files, this means opening and zip-parsing the EPUB on every scan pass for already-seen files. At watch-folder scale with a large backlog, this could add up, but is unlikely to be a bottleneck in practice (the watcher fires per-file, not a bulk scan). Minor concern.ON DUPLICATE KEY UPDATEdoes not updateoriginal_metadataIf a file is rejected and re-detected, status resets to PENDING_REVIEW but
original_metadataretains the value from first ingest. If the file changed on disk between scans (same path, new content), metadata is stale. Pre-existing design limitation; not introduced here.[MINOR] internal/bookdrop/meta_test.go — no test for the filename-fallback path
ExtractFileMetadatawith a non-existent file covers the error branch.ExtractFileMetadatawith a valid EPUB covers the success branch. There is no test for a non-EPUB file (e.g.,"Author - Title.pdf") to confirm the filename-parse result flows through correctly. The two branches in the function are covered, but the contract of the returned JSON for non-EPUB inputs is not asserted. Does not affect coverage gate (both branches covered) but leaves the behavior unspecified by tests.[MINOR] internal/app/app.go:514-517 — inconsistent lambda indentation
The closure body in
app.gohas misaligned indentation (thereturnline is over-indented vs the}), unlike the clean formatting inbuild_extended_deps.go.gofmtwould normalize this. No correctness impact.REVIEW VERDICT: 0 blocker, 0 major, 2 minor
aaaf21898b0dcd73ec16