Compare commits

..
84 Commits
Author SHA1 Message Date
John O'Keefe df556d8208 feat(auth): data-driven password requirement checkers
Release / build-and-push (push) Successful in 3m4s
GET /api/auth/password-rules (public, pre-auth) exposes the ACTIVE
complexity config from the settings registry; password_validation.ts
now fetches it and generates the checklist rows per active rule —
inactive requirements no longer linger as fake tasks, and the min
length mirrors the admin's setting instead of a hardcoded 8.

Surfaces: register, setup, admin create-user (Users page), profile
password change (self + admin variants, now gated on checklist pass;
misleading minlength=6 dropped — the server was always the enforcer).

Verified live: flip min_length=12 / number+special off via the
settings API → endpoint + register/profile/admin checklists re-render
row-for-row; enforcement follows (short rejected, rule-valid accepted
without digits/specials); restore defaults → checklists return.
2026-10-14 11:08:12 -04:00
John O'Keefe d95814ded3 fix(make): restore .PHONY declaration dropped by the guidelines cleanup
Release / build-and-push (push) Successful in 3m1s
The sed that removed the verify-guidelines lines also deleted the
.PHONY line (it contained those words), so 'make release' saw the
./release wrapper script as an up-to-date target and silently did
nothing. Restored with the verifier targets omitted.
2026-10-14 09:48:40 -04:00
John O'Keefe 17274bb868 fix(nas): refuse deletes while library storage is unreachable; real file removal; stat cache
Three changes from the network-drive audit:

1. Delete-ordering fix. Deleting a media item cascades its annotations,
   so the delete must never proceed while the item's library storage is
   unreachable — the DB row died, the file survived as an orphan, and
   the next scan re-imported it bare (annotations gone forever).
   resolveMediaStorage stats the library's folders: reachable → delete
   proceeds and the file is removed by ABSOLUTE path (the old code
   passed a library-relative path to os.Remove, which never resolved —
   file deletion has been a silent no-op); every folder unreachable →
   409 with 'storage unreachable', row and annotations intact.

2. Latency: resolveLibrary stats every library folder on every SSR
   render; folder reachability is now cached with a 30s TTL.

3. README: Network Storage section documenting the outage hysteresis
   (mark → archive → auto-restore), the 90-day retention purge
   (ARCHIVE_RETENTION_DAYS, 0 = never), refused deletes while storage
   is down, and watch mode not firing on network mounts.

Integration tests: storage-down bulk delete refuses with the row
intact; real-file delete removes the file from disk.
2026-10-14 09:00:08 -04:00
John O'Keefe f42720ac64 docs: retire PROJECT_GUIDELINES.md — library-visibility rule now in AGENTS.md + dev docs
PROJECT_GUIDELINES.md had drifted out of reality (podman-only rule
while the stack runs docker; verifier scripts wired to Makefile targets
nobody runs). Trashed it, its two verifier scripts, and the Makefile
targets; the rules that still matched reality (docker runtime, schema
in place until release, server binary only via Docker, services layer,
integration-test pool rules) are absorbed into the new AGENTS.md.

The library-visibility family-lens rule now has a real home:
- AGENTS.md: the standing rule agents auto-discover (new media
  endpoints MUST thread the visible-library set; 403, never silently
  pass; history of the 2026-10-13 leak).
- docs/developer/library-visibility.md: the full explanation (helpers,
  SQL pattern, per-item content gates, series per-library), linked
  from the developer docs index.
Stale doc links updated (docs index, contributing, development).
2026-10-13 20:18:42 -04:00
John O'Keefe 320f31086d fix(web): field-mapping + fragile-hx-vals sweep across profile and library surfaces
Field-by-field audit of the profile surface (set → save → reopen →
read back, driven through the emulator browser via CDP):

- getTemplateUserWithTheme (every SSR page + the /profile self-edit
  form) never mapped FirstName/LastName/CreatedAt — stored names
  rendered as empty inputs. Now mapped (timezone was already mapped).
- Admin profile-modal Timezone mapping fixed earlier this round held:
  all seven fields verified set → 200 + HX-Refresh → reopen → correct
  values, DB cross-checked.
- Role promote/demote via the card select verified against the DB (the
  PUT answers HX-Refresh, so the page reload shows the new role).
- Admin password reset verified end-to-end: new password logs in, old
  one 401s, original restored.
- admin_library folder-remove hx-vals was string-concatenated JSON —
  a folder path containing a quote or backslash made it invalid and
  the remove button silently died under htmx 2 (the same class as the
  profile-save bug). Now built with json.Marshal behind the js:
  prefix. Verified the generated attribute on the live page.
- Other hx-vals/hx-headers sites audited: constants and uuids only
  (safe charset), left alone.
2026-10-13 18:25:56 -04:00
John O'Keefe dc353b0f3f fix(web): profile modal always displayed UTC — Timezone never mapped
The admin profile-modal route hand-rolled its templates.User without
the Timezone field, so every selected?={ user.Timezone == … } in the
timezone select was false and the first option (UTC) rendered selected
for every user on every open. Saves worked fine — the modal just never
displayed what was stored, which read as 'not saving'. The route's
GetUser query already returns the column; it just wasn't mapped.
Verified end-to-end in the emulator browser: modal opens showing the
stored zone, save → HX-Refresh → reopen shows the new zone.
2026-10-13 15:05:40 -04:00
John O'Keefe 2a6e4b9ae5 fix(web): admin users cards redesigned for mobile readability
The single-line card squeezed identity, created date, role select, and
two buttons into one row — on a phone the username truncated to t…,
the email to te…, and the created date wrapped vertically one fragment
per line (10- / 13- / 2026 / …). Restructured into stacked full-width
rows: identity (avatar + username + email), created on its own line
(a full date never wraps), a labeled Role row under a divider, and
Edit/Delete as side-by-side flex-1 buttons (thumb-sized on mobile,
right-aligned sm+). Last-admin protections render identically in both
controls. Verified on the emulator (Pixel 9, Brave): full names/emails
visible, no wrapping, modal + role controls functional.
2026-10-13 15:00:59 -04:00
John O'Keefe 505fc48b23 fix(web): profile save silently died under htmx 2 — invalid-JSON hx-headers
Reproduced on the emulator via CDP: clicking Save Changes fired the
submit event, something preventDefault-ed it, and htmx aborted while
parsing hx-headers — 'Bearer ' + localStorage.getItem('token') is a JS
expression, and htmx 2 requires valid JSON (or a js: prefix) there.
The PUT never left the browser: zero htmx lifecycle events, no console
error, no toast. My earlier HX-Refresh fix was correct but unreachable
— the request died before it could ever be answered.

The header was htmx-1.x legacy and redundant: the browser already sends
the token cookie, which the auth middleware accepts. Removed hx-headers
from the profile form and both password-reset buttons.

Verified end-to-end in the emulator browser via CDP: Edit → change
timezone → Save → PUT 200, HX-Refresh reload, DB updated. The tab
reload mid-probe is the fix working.
2026-10-13 14:52:19 -04:00
John O'Keefe 57d83fd9af fix(web): profile save gives visible feedback (HX-Refresh)
The admin/user profile modal PUT succeeded but the response was a bare
JSON body swapped into a small div at the bottom of the still-open
modal — no close, no toast, nothing moved. It read as 'the button did
nothing' even though the save landed. HX-Request calls now answer with
HX-Refresh (re-render shows the saved values everywhere and closes the
modal) plus a visible confirmation. API callers keep the JSON body.
Silent-failure display already exists site-wide (toast.ts listens for
htmx:responseError), so failures were never the silent case.
2026-10-13 14:19:06 -04:00
John O'Keefe 0772f06d25 fix(tz): JWT context stub carries the user's timezone
The middleware's per-request user lookup (ghost-session guard) fetched
the full row and discarded it, then built the context stub without a
timezone — so every server-side conversion via the context user fell
back to UTC no matter what the handlers did. The stub now carries
Timezone from that same lookup (fetchLiveUser).

Live-verified: progress page renders 02:03 PM for a 14:03 New York
instant; an analytics session at 02:00 UTC buckets to 10-12 (10pm NY
the previous day) instead of 10-13.
2026-10-13 14:03:44 -04:00
John O'Keefe 1601548e6c fix(tz): server-side timezone conversion for all user-facing time displays
The audit showed only 5 of ~11 time-display surfaces converted to the
user's timezone; everything else rendered pgx-decoded UTC. The server
owns conversion — clients (web/app/KOReader) receive display-ready
strings or machine-readable instants and never convert.

- Progress page: LastUpdated formatted in the requesting user's tz
  (was raw UTC).
- Analytics: date-range defaults, day buckets, most-active-day, device
  last-sync, and popular-book last-read all computed in the user's tz —
  evening sessions no longer land on the next UTC day. Also fixed the
  end-date boundary: the query bounds with <= and the end bound was
  midnight, so the end date's own sessions (including 'today') never
  appeared.
- Queue page: new created_at_display field (admin tz) rendered by the
  template; created_at stays RFC3339 for API consumers.
- Admin hash-conflicts SSR bridge: detected date rendered in the
  admin's tz.
- OPDS: .UTC() before the Z-suffixed feed timestamps (honesty fix —
  correct today only because the session zone happens to be UTC).
- KOReader annotation datetimes deliberately untouched (device owns its
  display).
2026-10-13 13:59:49 -04:00
John O'Keefe 740213f0a4 fix(auth): new users inherit the system-wide default timezone
default_timezone existed as a system setting (admin-editable, exposed
via SettingsRegistry.DefaultTimezone) but nothing read it at user
creation — the CreateUser query didn't even carry the column, so every
new user got the DB default UTC regardless. Both creation paths
(public register + admin create) now seed the user's timezone from the
setting, validated with time.LoadLocation and falling back to UTC when
unwired/empty/invalid.

Test-infra fixes surfaced by the run: TestUpdateProfileSelfEdit was
self-editing the SHARED admin (renaming it past the harness cleanup's
preserve-list, leaking a second admin row that broke the last-admin
tests) — it now edits a dedicated user with a unique name; the
last-admin tests get an ensureSingleAdmin precondition helper; the
websocket collection name is run-unique (non-test names persist in the
shared DB and UNIQUE(user_id,name) 409s on rerun).
2026-10-13 13:17:12 -04:00
John O'Keefe bc6ce41762 fix(web): password requirement indicators on registration and admin create-user
The /register checklist was dead markup: initPasswordValidation() was
only ever called on the setup page, so the requirement rows never
updated and the Register button stayed disabled forever — web
self-registration was effectively broken. The register Alpine component
now initializes it.

The validator is parameterized (field/submit ids, confirm + username/
email checks optional) so the admin Create User form reuses it: confirm
field, live requirement rows, and the Create button gated on all checks
passing. Requirements mirror the server's passwordcomplex rules
(8+ chars, upper/lower/number/special — the hardcoded defaults; server-
tuned rule values are a future nicety, the server remains the enforcer
either way).
2026-10-13 12:38:26 -04:00
John O'Keefe f3c9d4b4e8 fix(web): admin users list reworked to the library-management card pattern
The md:-flipped table/card split still broke at tablet widths and with
the nav sidebar open — a viewport breakpoint cannot see the sidebar
eating content width. Replaced the table entirely with the same fluid
card list admin_library uses (flex + min-w-0/truncate identity block,
fixed action cluster, flex-wrap): it reflows continuously at every
width instead of stepping between two layouts. The duplicate-DOM-id
suffixing from the previous two-rendering approach is gone with it.
2026-10-13 12:29:43 -04:00
John O'Keefe 300866ca67 fix(web): viewport meta on all admin/profile pages; admin users page reworked for mobile
- The desktop-on-Android bug: 8 page templates (admin, admin_settings,
  admin_users, admin_library, admin_archived, admin_hash_conflicts,
  admin_processing_issues, profile) lacked the viewport meta, so mobile
  browsers fell back to a ~980px layout viewport and rendered the
  desktop layout. Added the same meta the other 21 pages already had —
  partials without a head were skipped deliberately.
- Admin Users page: the create-user form no longer dominates the page —
  it is hidden behind an explicit '+ Create User' button (Alpine
  x-show/x-cloak, the site-wide convention) with a Close control.
- The user list is responsive: table on md+, stacked per-user cards
  below. Role/delete controls are shared partials with DOM-id suffixes
  so both renderings keep unique swap targets (last-admin protections
  preserved in both).
2026-10-13 12:24:12 -04:00
John O'Keefe 431fec05ad feat(security): library visibility enforced at the source; per-library series; annotation ownership; admin user creation
The family-lens hardening: library_visibility was only enforced where
file bytes flowed (download/reader/OPDS) — metadata surfaces leaked
hidden libraries to any authenticated account, and registration was
open by default with no admin-side user creation.

- Series are grouped PER LIBRARY now (same name in two libraries =
  two entries: manga adaptations / LN splits no longer merge);
  /api/series aggregates only the requester's VISIBLE libraries and
  /api/series/books REQUIRES a visible library_id (breaking contract;
  web + app clients updated in the same change).
- /api/media-items, item detail, and the reader content endpoints
  (chapters/panels/outline/THUMBNAILS — page images leaked unguarded
  next to a gated GetPage) all enforce the visible set; resolveLibrary
  no longer honors a hand-typed library_id outside the visible set.
- Annotation/note/bookmark mutations (update/delete/tombstone, sync
  LWW paths incl. conflict resolution) carry ownership predicates:
  another account's bare row id is a 404, not a silent delete.
- Admin user creation: POST /api/auth/users (AdminMiddleware) + a
  Create User card in the admin Users UI — accounts stay creatable
  with self-service registration OFF, which is now the schema default
  (fresh installs; the first-admin bootstrap exception is unchanged).
- Tests: series contract re-pinned (per-library, visibility 403s),
  new ownership + admin-create integration suites, OPDS all=1 test
  now passes an explicit page size (shared-DB growth made the fresh
  books fall off page 1), device-cap helper switched to the admin
  endpoint.
2026-10-13 12:03:01 -04:00
John O'Keefe 97fbdf19fb pin foliate-js 7796b05 — pdf text-layer dedupe on re-render (v0.5.3) 2026-10-13 10:39:51 -04:00
John O'Keefe a36cee4f85 chore: ignore local export directory
Release / build-and-push (push) Successful in 2m54s
The export/ directory holds database extracts (annotations, reading
positions, book metadata) pulled from production for debugging. The
contents are personal data and must never be committed.
2026-10-12 20:47:05 -04:00
John O'Keefe 14947608be chore(scripts): add synthetic torture EPUB generator
scripts/build_torture_epub.py deterministically builds a small EPUB
exercising every CRE-xpointer to CFI conversion hazard observed in the
wild, one chapter per hazard class: multi-text-node paragraphs (verse
spans, inline markup), whitespace-only inline spans and comments,
drop caps and footnote anchors, br-separated poetry with speaker and
line-number spans, nested blockquotes/lists/tables/definition lists,
surrogate pairs and ZWJ emoji, Hebrew niqqud, Arabic RTL, combining
diacritics, invisible characters, HTML entities, CJK ruby, a 400-
paragraph chapter with heavy phrase repetition for search
disambiguation, and edge constructs (span-first paragraphs, empty
spans, adjacent same-tag spans, deep same-tag nesting).

Default output lands in the Ebooks library for real-device testing
via OPDS; the script takes an output path argument for use as a test
fixture.
2026-10-12 20:46:40 -04:00
John O'Keefe 03a6040360 test(sync): add manual repro harness for CRE-to-CFI highlight conversion
TestManualCleanRepro replays a real device highlight export through the
same conversion path the sync handler uses, diffs the output against
the CFIs stored at ingest time, and computes a ground-truth oracle by
locating each selection in the flattened document itself. Classifies
every row as healthy / diff-base / backwards for both the converter
and the stored data. Skips automatically when fixture files are not
present on the machine (manual test, like cfi_verify_manual_test.go).

REPRO_VERBOSE=1 prints per-row detail; REPRO_EMIT_SQL=<path> emits
idempotent UPDATE statements that heal database rows written by the
old converter from the preserved raw device positions - used to
backfill dev and validated against production data.
2026-10-12 20:46:08 -04:00
John O'Keefe 93a468b435 fix(reader): emit two-subpath range CFIs for multi-node highlights
toRangeCfi required stored start and end CFIs to share a base path and
otherwise fell back to a bare point CFI, which resolves to a collapsed
range and paints nothing - or painted from the wrong anchor after end
repair. A legitimate selection crossing inline elements anchors at two
different text nodes by definition, so requiring a shared base path
discarded correct endpoints.

Build the range parent from the endpoints' longest shared step prefix
(down to the bare spine step when needed) and emit each divergent
remainder per the EPUB CFI range grammar, epubcfi(parent,start,end).
Degenerate-end repair (missing end, or end before start) now only
applies when both sides terminate on the SAME node - equal remaining
step count with different step values is a valid two-node range, not a
degenerate pair; document order is verified for those instead.

Verified with foliate's own parser against the real chapter XHTML:
ranges resolve to exactly the selected text for same-node, two-node,
and repaired-degenerate cases.
2026-10-12 20:45:41 -04:00
John O'Keefe bb3aef517c fix(sync): anchor device highlight ranges to selection text
KOReader highlights converted to wrong or unresolvable web CFIs
whenever a selection crossed text-node boundaries. Three compounding
defects: ParseCREXPointer discarded the text()[N] node index and
applied .offset as a global block offset; the end anchor converted
with an empty context (blind structural landing, zero verification);
and the block-containment check accepted wrong-node landings because
a selection's text always occurs somewhere in its own block. pos0 and
pos1 were converted independently, yielding mismatched start/end base
paths and backwards ranges - highlights stored but never painted, or
painted at the wrong location, in the web reader.

Add ConvertHighlightRange: both endpoints are anchored by locating the
selection text in the flattened document text (whitespace-normalized,
crossing block boundaries), with the device locators demoted to
disambiguation hints. A single document match yields a same-spine,
ordered start/end pair by construction. Falls back to the legacy
per-endpoint path when the text cannot be located, so behavior is
never worse than before.

Also fix the shared flatten stream: zero-width format characters
(ZWSP/ZWNJ/BOM) and whitespace-only inline spans (<span> </span>)
now act as word separators instead of fusing adjacent words, which
prevented text matching in real books.

Verified against 28 real device highlights from production: converter
output now matches the text-search oracle on all of them (previously
8/28 correct, 18 with mismatched base paths, 2 backwards).
2026-10-12 20:45:10 -04:00
John O'Keefe cbdddc095e fix(web): PDF adjust — remove the leftover line-band filter from the endpoint drag
Release / build-and-push (push) Successful in 2m39s
The "full 2D" commit (5a0d9e0) added the tip aim but never removed the
y-band candidate filter from pdfAdjustHandleMove — vertical handle
drags still snapped to the boundary rect's own line, so up/down
movement was impossible (the user's original report). With the filter
gone: the end handle drags down a line and the extent extends with
clean text — verified with real touch on the emulator (down one line,
release, PUT applied: 6 per-line rects).
2026-10-11 20:44:32 -04:00
John O'Keefe 5a0d9e0e17 feat(web): endpoint handles drag vertically — full 2D tip-aimed targeting
User report after the lifted-handle change: "I cannot go up or down,
just side to side" — a consequence of the line-band pin added with the
lift (the floating finger sits above the text; a band-constrained scan
ignored vertical movement entirely).

The endpoint drag is now full 2D: the finger position (plus the
~15px body-center-to-tip offset) maps to page fractions and the
nearest glyph re-anchors the grabbed end — up/down crosses lines,
side-to-side moves along them, clamped so the extent never inverts.
The boundary rect's own band constraint is gone; sliver rects remain
structurally impossible (rects rebuild from the char-table span as
per-line unions).

Direct-fraction probes verified: (0.4, 0.098) → mid line 1; (0.2,
0.117) → line 2 head; (0.72, 0.117) → line 2 mid — spans cluster into
the right per-line boxes. The dot-listener plumbing (pixels → map →
fractions → scan) verified by instrumented probe on the same load.
Final desktop finger pass pending (emulator input pipeline wedged).
2026-10-11 18:15:34 -04:00
John O'Keefe 19619ef817 fix(web): raise the adjust teardrops — tips rest above the glyph tops
User report: the teardrop tip touched the letters. Raised the handle
boxes 8px (tip now rests at the line-box top, ~4px above the glyph
tops) — matches the Android handle spacing the app leg shipped.
2026-10-11 17:57:03 -04:00
John O'Keefe a28ae5c88d feat(web): adjust handles float above the text with tips marking the boundary
User report: the handles covered the highlighted text. The dots now
hang ABOVE the extent's boundary corners (teardrop tip down at the
first rect's top-left / last rect's top-right), so the highlighted
line stays readable.

Same treatment as the app leg: the nearest-char scan in the endpoint
drag is band-constrained to the boundary rect's own line (the lifted
finger floats ABOVE the text — a whole-table scan re-anchored onto the
line above). Cross-line changes go through re-selection as before.
2026-10-11 17:25:58 -04:00
John O'Keefe 1c209408cc fix(web): PDF adjust — three compounding coordinate/table bugs, found by instrumented drag
User report (desktop Brave, fresh private window): the endpoint handle
"jumped several lines down and stuck". The instrumented drag exposed
the chain:

1. THE bug: the endpoint handleMove API takes PAGE FRACTIONS, but the
   dot's pointermove listener passed raw e.clientX/clientY PIXELS.
   Scanning for the nearest glyph to (296, 170) in a 0–1 fraction space
   picks the bottom-right-most glyph — the extent leaped to the page's
   end and never moved again (stuck). Engine-independent: failed
   identically on Chrome/Brave/Firefox desktops; my synthetic tests
   masked it by passing pre-converted fractions. handleMove now maps
   host px → fractions internally (one contract).

2. pdfPageMapping mixed coordinate spaces: the iframe-internal canvas
   rect with the host iframe rect. Whether that combination is valid
   depends on the engine's cross-document getBoundingClientRect
   semantics. Replaced with the page WRAPPER element (the iframe's
   parent — the same box the fork's rect-annotation overlay fills),
   measured in a single host context. Handles now align with the
   painted highlights by construction.

3. Char-table hygiene: per-char boxes are LINE-HEIGHT boxes (~one line
   gap tall). Dedupe at a full box height ate the line-below char in
   the same x-column (silent character loss); clustering at full box
   height never split lines (merged 0.45-tall "line" boxes). Dedupe
   now tolerates 0.75 of the SHORTER box; clustering breaks at 0.6 of
   the char's own height (inter-line gap ≈ 1.0× breaks, baseline
   variance ≈ 0.1× stays).

Verified end-to-end (CDP synthetic pointer path): drag to (0.72, 0.117)
→ span extends line 1 → line 2, 2 per-line rects saved, clean
multi-line text. Direct-fraction probes and the pointer path now agree
on the same load.
2026-10-11 13:01:16 -04:00
John O'Keefe 93f020f2b5 fix(web): PDF adjust handles — create once, reposition only
User report (desktop): the endpoint handles wouldn't move.
renderPdfAdjustHandles rebuilt the handle layer via innerHTML="" on
every preview frame — the first drag move triggered a repaint that
DESTROYED the dot being dragged. A removed element loses pointer
capture, so real mouse/touch drags died silently after one pixel; the
pointerup never reached the detached dot either, so nothing saved and
the session preview reverted — the same mechanism behind the earlier
"resets to the original selection" report. CDP tests masked it because
synthetic events dispatched directly at the detached node still ran its
listeners.

The two dots are now created once per session and every later call only
repositions them (style updates move nothing the listeners live on).
Grip-era code had this reuse pattern; the endpoint rewrite regressed it.

Real-touch verified on the emulator (adb-injected drag): the handle
tracks, the release saves a multi-line extent with clean recomputed
text (3 rects, PUT applied).
2026-10-11 11:21:51 -04:00
John O'Keefe a3ecac3507 fix(server): Cache-Control no-cache on /static — heuristic caching served stale bundles
The static handler sent no Cache-Control, so browsers fell back to
heuristic caching (10% of age since Last-Modified) and kept serving a
stale reader.js for hours after a deploy — fixed web bugs reported as
"not fixed". no-cache forces revalidation every load; the filesystem
Last-Modified/If-Modified-Since pair keeps unchanged loads cheap.

Found while closing out the PDF adjust v2 session: the user's Chrome
was running a pre-endpoint bundle, which read as "the web has not
been fixed".
2026-10-11 09:50:42 -04:00
John O'Keefe db3b5b3d62 fix(web): endpoint handles verified end-to-end — multi-line extent saved (5 rects, clean text); note: footnote markers between lines are geometrically included by design 2026-10-10 23:19:35 -04:00
John O'Keefe f69e659d94 feat(web): PDF adjust v2 — re-selection replaces the extent; vertical grip clamp
Multi-line partial extents ("line 1's tail + the first words of line
2") were inexpressible with one-rect-per-drag grips, and the vertical
drags produced sliver rects whose text recompute swept fragments. The
web's re-selection uses what creation already uses: a selection made
during a rect-edge session is intercepted in checkPDFSelectionFor —
rects rebuild from the selection's per-line client rects, text re-reads
via the deduped glyph boxes, saved immediately (grip release-to-save
parity), and the create popover is suppressed for that gesture.

Vertical grip drags clamp to the rect's OWN line box (band ±quarter
height) — multi-line changes go through re-selection, slivers are gone.

Also verifies clean under the duplicated text-layer span sets (the
fork's pdf.js render never clears .textLayer — open bug): the text
recompute dedupes by glyph overlap.

CDP-verified end-to-end: session → multi-line selection → 6-rect
extent + clean text persisted (PUT 200); create popover correctly
suppressed during the session.
2026-10-10 18:25:29 -04:00
John O'Keefe dbeefb395c fix(web): PDF adjust text recompute — overlap-based glyph dedupe
User report: extending a highlight doubled letters into the recomputed
selection text ("aauuthenticcity off ... ruunss iinnto"). The exact-
position dedupe key (toFixed(3)) only collapsed duplicate glyphs whose
re-render copies landed inside the same rounding bucket — pdf.js
re-render copies can sit SUB-PIXEL SHIFTED, so per character the jitter
sometimes straddled a boundary and both copies survived, partially.

Dedupe by overlap instead: a glyph is a duplicate when a kept glyph on
the same line (y0 within half a glyph height) overlaps more than half
the narrower glyph's width. Distinct glyphs — including tight pairs —
never reach that; shifted duplicate copies always do. The snap tables
share the source, so their boundary lists are clean too.

The root cause remains the fork's pdf.js render() appending a fresh
span set without clearing .textLayer (open bug, fork-cycle fix); this
is the defensive layer that makes the adjust feature correct regardless.

Repaired the affected highlight's stored text with the fixed recompute
(verified: the same live duplicated DOM now yields the exact line text
with zero doubling).
2026-10-10 14:25:48 -04:00
John O'Keefe 8acaeddda5 fix(web): detail banner — a failed annotation fetch must not wipe its rows
fetchBannerAnnotations published the rebuilt rows unconditionally, with
a failed endpoint's list defaulted to empty — so one failing request
(a single handler 500, an auth blip on one call) silently wiped that
annotation kind from the banner while the other kind stayed, and the
empty state could claim "No notes or highlights on this book yet" over
real data. A full outage was already safe (the thrown fetch takes the
catch path, which keeps previous rows and toasts) — the partial case
was the hole, spotted while mirroring the app's fetch-wipe fix
(bookhoard-app 07bbb4c) onto the web.

Both responses must now succeed before the rows are published;
otherwise the previous rows stay and the next banner open retries
(bannerLoaded only sets on success). Verified on the emulator: banner
renders 3 rows, a full outage keeps them with bannerError recorded,
and a post-recovery refetch re-renders from live data.
2026-10-10 13:56:32 -04:00
John O'Keefe 361fdaab39 fix(web): PDF rect-edge adjust — a start-edge drag pins the far edge
The left-edge drag kept the rect width instead of anchoring the opposite
edge, so shrinking from the left silently dragged the right edge along
and the recomputed text grew words at the far end (found by the Android
device test of the app leg). Same fix for the top edge / bottom pin.
The end-edge branches were already correct.
2026-10-10 11:47:41 -04:00
John O'Keefe 4d1fb05e49 feat(web): PDF highlight extent adjustment — rect-edge grips with word-token snap
The reflowable adjust (objective 9) had no fixed-layout counterpart: the
popover's Adjust button relied on adjustContext, which is only stashed by
the reflowable show-annotation path, so PDF rows showed a button that
silently did nothing. PDF highlights persist as page-fraction rect
payloads ({v:1,page,rects}), so their adjustment is geometry, not range
re-anchoring.

- startAnyAdjust routes the popover button by format; startPdfAdjust
  runs a rect-edge session: 4 grips per rect (all edges, one rect per
  drag), live preview through the renderer's own addRectAnnotation
  (replace-by-key — no fork change), commit via saveHighlightChanges.
- Snap vocabulary mirrors snapBoundary: maximal letter runs are atomic
  tokens (start edge takes the word start, end edge the word end), each
  punctuation glyph is its own token with both edges grabbable (a period
  or line-break hyphen can be taken or shed precisely). Horizontal edges
  snap to line top/bottom bands (own line + neighbours).
- selection_text is recomputed from the glyph boxes inside the final
  rects (reading order, lines joined with spaces) so the stored text
  stays honest with the extent.
- Cancel paths mirror the reflowable session: page turn (renderer
  relocate) discards and restores; a committed session skips the restore.
- getContents() from the fixed-layout renderer carries no index — page
  iframe docs are mapped through a WeakMap filled by the view's load
  events. Pointer mapping goes through the canvas's post-transform rect
  (the dpr denominator lesson) via the fork's own convertPoint ratios.
- pdfCharBoxes dedupes by box key: pdf.js re-renders append a second
  span set into the same .textLayer without clearing, and undeduped
  glyphs doubled the recomputed text.

Device-verified (emulator Chrome + CDP): popover route, grip placement,
word-atomic shrink ("But this" -> "this"), extension to the exact
line-break hyphen boundary (0.8777), far-drag clamp at the last token,
page-turn cancel, and the reflowable adjust still routing to handles.
2026-10-10 11:22:09 -04:00
John O'Keefe 679e0f9116 fix(server): ghost 401 must abort the chain — c.JSON's nil error let the request continue
Release / build-and-push (push) Successful in 3m21s
The guard returned c.JSON(401, ...), whose nil error told echo the
SuccessHandler succeeded — the pipeline then continued into the route
handler, which type-asserted the never-set "user" context value and
panic-recovered as a 500 appended after the 401 body. The status code
happened to survive (first write wins), which is why the pin test
passed, but every ghost request also executed the target handler with
no user — the exact unauthenticated-execution class the guard exists to
prevent.

Return echo.NewHTTPError(401, ...) instead: non-nil aborts the chain
and renders {"message":"user_not_found: ..."}; the pin test now asserts
that shape. Full suite: 219 groups, zero failures.
2026-10-09 22:41:21 -04:00
John O'Keefe 9cd6356892 feat(server): ghost-session guard — JWTs for deleted users stop authenticating
The JWT middleware validated signature + expiry and fabricated the
request user purely from token claims, so a signature-valid token for a
DELETED user kept full API access until token expiry — observed live on
hardware (a phone session survived the test suite's user-table
recreation and kept serving its true empty sets; open handoff bug 1).

An ensureUserExistsMiddleware already existed but was only wired on the
frontend SSR group (frontend.go) — every /api group used bare
createJWTMiddleware. The existence check now lives INSIDE
createJWTMiddleware's SuccessHandler so every protected group (SSR and
API alike) gets it:

- After claim validation, GetUser by the token's user_id; a missing row
  clears the token cookie and answers 401 user_not_found for API calls
  (the app's TokenAuthenticator then re-logins with stored credentials)
  or redirects to /login?session=invalid for page requests — same
  shape-aware split the error handler already uses.
- Cost: one primary-key lookup per authenticated request (~0.2ms local);
  instant revocation, no TTL semantics. The redundant SSR-only
  middleware is deleted (its redirect behavior is preserved inside the
  guard).

The guard exposed two processing-issues tests that were unknowingly
RELYING on ghost semantics: they nested a second setupTestServer whose
bootstrap deleted the first setup's regular user, then asserted 403
from the dead token. De-nested: a createLibraryDirect helper inserts
the library under the live setup's user, no second bootstrap.

New TestDeletedUserTokenRejected pins the contract: token works while
the user exists -> 401 user_not_found after deletion. Full suite: 219
groups, zero failures.
2026-10-09 22:33:30 -04:00
John O'Keefe 4f05a232fe chore(server): delete dead unrouted UpdateUniversalProgress handler
Handler.UpdateUniversalProgress (handlers/progress.go) had no route
registration anywhere (router/progress.go only wires the history route
to the scanner handler) and no callers — a legacy progress-write path
that bypassed the verify/heal pipeline entirely. Deleting it removes
the last bypass-shaped writer that could poison progress rows without
verification; conflict-resolution apply remains the only deliberate
direct write (user-approved winner rows).

Pure deletion: 115 lines, no route, no callers, no behavior change.
2026-10-09 18:22:23 -04:00
John O'Keefe 69516ae165 feat(server): progress locator coherence — percentage-only writes clear stale anchors
The verify/heal pipeline verified only INBOUND context-bearing writes;
stored-row coherence was never enforced. SaveProgress seeds missing
fields from the existing row, so every percentage-only write (KOReader
low-confidence conversion, KOReader no-pointer payloads, Kobo markup
ReadingSync / analytics / sync-from-server, locator-less queue entries)
silently re-paired its NEW percentage with the PREVIOUS writer's stale
CFI+context — the incoherent row shape that fed the client-side
far-anchor restore floor (and, via the read-side anchor handles, could
even hand a first-party client the OLD position's scroll address).

KOReader's converter already declared the right intent ("storing
percentage only — the row's percentage restores approximately"); the
seeding betrayed it. SaveProgressRequest gains ClearLocator, set by the
percentage-only call sites, and the seeding rule honors it: stale
epubcfi/context/character_offset clear (the upsert assigns columns
directly, so NULL sticks) unless the request carries its own. A
request-carried context survives so the verify/heal pass can still mint
a fresh anchor from it; the HTTP merge path (field-absent PUTs
preserve) and all anchor-bearing writes are untouched. The fixed-layout
branch already cleared locators unconditionally — unchanged.

Consumer audit: percentage — the only thing every sync round-trip
exchanges — is untouched on all paths; KOReader pulls on locator-less
rows already support the nil-CFI shape (builder guards
progress.Epubcfi.Valid); first-party restores go percentage-first
instead of floor-fighting a stale anchor.

Tests: TestProgressWeb_LocatorCoherence pins clear-on-declared,
request-carried-context survival, and merge-path preservation. Full
suite with the change: 218 groups, zero failures.
2026-10-09 18:22:15 -04:00
John O'Keefe f24a2d6c22 test(server): fixture collisions fixed, stale contract asserts re-anchored, gofmt — suite now fully green
Closes the remaining integration-suite failures (handoff open bug 3),
line by line:

- Fixture family (the upsert equivalence): tags_filter_test
  ("Science Fiction" found 1 of 3; "Fantasy" 404'd on zero results),
  media_item_isbn_test (normalization read back the first case's ISBN
  everywhere; edge cases saw a nil ISBN; pagination collapsed 5
  creates onto 1 row), search_unified_test (its 3 books were actually
  1 row + two no-op creates) — all POSTed a constant file_path into
  one library while CreateMediaItem upserts ON CONFLICT
  (library_id, file_path). Every fixture now uses unique per-item
  paths, the suite's universal convention elsewhere. TestTagsFilter
  confirmed same-family: counts came right with no second bug.
- New TestMediaItemCreateDuplicatePath pins the honest-create
  contract from da13898: 201 for the first create; 200 + duplicate
  flag + the EXISTING row (never a 201 lie), second payload not
  overwritten.
- library_browse_test: the admin subtest browsed the container's
  /tmp and required it non-empty — env-coupled (empty /tmp → "was
  []"). Now browses a self-created t.TempDir with two subdirs and a
  decoy file, asserting ONLY directories are listed.
- series_integration_test: three stale assertions re-anchored to the
  shipped contracts, each with a ground-truth comment: /dashboard/
  sections key identity on the collection NAME (BuildSections ID =
  CollectionName; the web/app reorder + hidden prefs persist those
  strings) — the kebab query_type is the storage key and is not
  exposed; /api/series/books is name-ONLY (the app client and the
  web SSR page both send name only; library_id is ignored, never
  validated); /api/series is optional-scope BY DESIGN (GetDistinct-
  Series: narg IS NULL OR library_id = narg aggregates across
  libraries when omitted). The former 400-on-missing-library_id
  expectations asserted validation that never shipped.
- gofmt rider on three untouched-in-this-arc test files
  (dashboard_integration, search, test_helpers).

Full suite with -parallel=1: 217 groups pass, zero failures.
2026-10-09 17:11:12 -04:00
John O'Keefe da13898046 feat(server): POST /api/media-items answers honestly on a duplicate file_path
The create endpoint shared the scanner's upsert (ON CONFLICT
(library_id, file_path) DO UPDATE SET updated_at = NOW() RETURNING *) —
so a POST naming an already-registered file returned 201 CREATED with
the OLD row while silently discarding everything the caller submitted.
Nothing depended on that lie: the app only GETs/PUTs media items, the
web only searches/purges/deletes, KOReader never calls it — the
endpoint's create path had zero production callers.

Split the API path from the scanner's idempotent upsert, which is kept
verbatim in CreateMediaItem (re-scan dedup remains its safety net):

- CreateMediaItemIfNew: same insert with ON CONFLICT DO NOTHING — the
  loser of a create race writes nothing and returns no row.
- GetMediaItemByLibraryAndPath: fetch the existing row for the
  duplicate response.
- Handler branch: on pgx.ErrNoRows → 200 OK with
  {"duplicate": true, "message": ..., "item": <existing row>} instead
  of a 201 pretending it created something. Real creates still answer
  201 with the item as before.

Bulk-import shape (the reason for 200+flag over 409): a re-run after a
mid-import failure is harmless and countable — each response tells the
caller exactly which of the two things happened.

Verified by the full integration suite: 217 groups pass, zero failures,
including a new pin of the duplicate contract (same file_path twice in
one library → 201 then 200/duplicate:true, second payload not
overwritten).
2026-10-09 17:10:56 -04:00
John O'Keefe 1a2d42a244 chore(server): delete dead ParseEbook 501 placeholder
Born dead in e7ce0cb (2026-04-03, backend reader infrastructure):
the "Phase 2.5" plan assumed MOBI/AZW3/DOCX/RTF would need server-side
parsing, so ParseEbook was written as the endpoint that would do it —
but it only ever returned 501 "not yet implemented", and no route in
router/reader.go ever pointed at it. No caller in Go, templates, or
web JS/TS ever existed either.

The eventual architecture never needed it: the web reader hands the
raw file to foliate-js and parses client-side (MOBI renders via the
fork's mobi.js — verified live; DOCX via mammoth; RTF/PDB shipped).
By now it was also stale — it 501'd DOCX/RTF as "not yet implemented"
while both work, and offered an AZW3 path that was dropped entirely.

Pure deletion: no route registration, no references, no tests, no
behavior change. go build ./... clean.
2026-10-09 15:05:44 -04:00
John O'Keefe 208fc2c4d6 fix(server): kobo markup panic — wire LibraryService into the test harness, guard typed-nil resolver
TestProgressWeb_KoboIntegration 500s'd on every markup with a
last-read-place bookmark: the panic-recovered stack was a nil pointer
dereference in LibraryService.ResolveMediaPath (s.db deref), reached
from KoboHandler.convertKoboCFIToStandard during kepub CFI conversion.

Two-layer bug:

1. Test harness (root cause): the router.Config built in
   test_helpers_test.go omitted LibraryService, so router/sync.go's
   SetLibraryService(cfg.LibraryService) stored a nil
   *services.LibraryService in the interface field. Production wiring
   (main.go:246) was always correct.
2. Latent trap (hardening): `h.libraryService != nil` at the
   convertKoboCFIToStandard call site can never catch a typed nil —
   an interface holding a nil *LibraryService is non-nil. ResolveMediaPath
   now degrades with a "library service not configured" error instead
   of panicking on a nil receiver; callers already handle the error
   by skipping CFI conversion.

Harness-only fix, zero production behavior change: the endpoint now
returns a clean 200 with the position saved wherever kepub conversion
is unavailable instead of a panic-recovered 500.

Verified: TestProgressWeb_KoboIntegration passes all three subtests
(401 unauthenticated, ReadingSync→last-read-place percentage
preservation, last-read-place without prior ReadingSync) and on
immediate rerun — previously it failed every run.
2026-10-09 14:53:48 -04:00
John O'Keefe 18969a0f01 test(server): comic negative-case assertions target display values, not admin-modal labels
The whole-body "should not display Manga Type / Scan Info" assertions
could never pass: every book-detail page for an admin carries the
edit modal, whose form labels ("Manga Type", "Scan Information") and
option texts ("Yes (Right to Left)") share vocabulary with the
metadata display block. This was pre-existing rot — the test never
ran to completion before today because an earlier panic killed the
process first.

Replaced with value-level checks that only the display block could
produce ("yes and right to left", "B&W"): absent for plain ebooks and
unknown-manga-type items, matching the templ guards in
book_detail.templ. Emoji-icon asserts dropped from the RTL/Story-Arc
positive steps — the redesigned comic badge renders text chips
without icons.
2026-10-09 11:04:39 -04:00
John O'Keefe 843f35bbfc test(server): runs leave no residue — auto-cleanup for every resource the suite creates
Symptom: repeated full-suite runs poisoned the dev database with
leaked rows (duplicate Prefs Scope libraries, colliding device names,
saved-filter name conflicts on rerun) because tests created rows but
never deleted them, and stale-binary runs left rows the new cleanup
could not match.

- test_helpers: library creation now registers t.Cleanup DELETE (the
  leak root cause — createTestLibraryWithFolder never cleaned up).
  Suite-wide cleanup passes for libraries/collections/devices/
  saved-filters run both at setup (pre-pass against legacy residue)
  and in t.Cleanup (no per-run residue). Preserve the admin's real
  libraries; delete only convention-named test rows.
- setup seeds canonical system collections every run keyed on
  query_type (idempotent — heals renames/stale tombstones), moved out
  of the preserved-admin branch so it runs for every suite start.
- dashboard_integration: scope-testing library renamed into the
  test-row convention; ordering assertions on the restored
  "Want to Read" collection preserved.
- filters_test: filter names moved into the convention (rerun-safe —
  name-uniqueness 409 no longer trips second runs); five  .(string)
  type assertions panic-proofed with require.True guards (a panic here
  used to kill the whole test process mid-run, which is why a stack of
  "never ran" failures surfaced at once later).
- device_test: device names moved into the convention (ILIKE %test%
  hit them; duplicate-device-name conflicts gone).
2026-10-09 11:04:34 -04:00
John O'Keefe 7ce73213ae feat(server): system collections become permanent — soft-delete + in-place restore + query_type-keyed provisioning
The old model hard-deleted system collections and re-created them at
the tail, resurrecting stale dedups and breaking ordering. Redesign
(user-directed B-final):

- schema.sql: collections.deleted_at TIMESTAMPTZ (idempotent ALTER —
  the whole schema is re-applied on every boot via executeSchema).
- queries.sql: every collection list/read query filters
  deleted_at IS NULL; new GetSystemCollectionByQueryType :one
  (unfiltered, sees tombstones), TombstoneSystemCollection :exec,
  ClearCollectionItems :exec, RestoreSystemCollectionRow :one
  (in-place metadata reset + un-tombstone; collections have no
  updated_at column — ResetUpdatedAt was a 500 caught by tests).
  sqlc regenerated.
- collection_service.DeleteCollection: system collections are
  tombstoned (preserving position/ownership); user collections keep
  the hard delete. No ErrNoRows to the client (delete is idempotent).
- dashboard_service.RestoreSystemCollection: resolve by query_type;
  found → in-place restore (ClearCollectionItems + metadata reset,
  un-tombstoned); missing → provision fresh. "Want to Read"/etc.
  stay at their home positions forever.
- handlers/collections.CreateCollection: name-taken is 409, not 500.
- handlers/auth.CreateDefaultCollectionsForUser: idempotent by
  (user_id, query_type) — update-in-place when a row for that query
  type exists (heals renames/tombstones), insert when missing; this
  also fixes the duplicate-system-collections regression at source.
2026-10-09 11:04:24 -04:00
John O'Keefe 31f939002f feat(web): 'a' keyboard shortcut toggles the annotations drawer
The drawer was the only top-bar tool without a keyboard shortcut, so the
shortcuts & gestures help modal had to describe it as '📝 button'. Bind
plain 'a' to toggleBookmarks(), following the same pattern as the
existing t/s/b letter shortcuts.

- reader.ts: new 'a' branch in handleKeydown, guarded against
  Ctrl/Cmd/Alt so Select All (a common reader action when selecting
  text to highlight/copy) is never intercepted; typing guard already
  protects note textareas, search box, and selects. Works in both the
  host page and book-content iframes since both keydown listeners route
  through handleKeydown.
- reader.templ: help modal row now shows <kbd>a</kbd> like the other
  letter shortcuts; 📝 button tooltip gains '(a)' to match its
  siblings (b), (/), (s), (?).
- reader_templ.go: regenerated.
2026-10-08 15:12:27 -04:00
John O'Keefe f83b36167e style(web): location-note rows lose the color bar — only highlights carry a color (user review) 2026-10-08 14:57:41 -04:00
John O'Keefe d5d8e3e173 style(web): drop the NOTE label from the expanded boundary — divider reads on its own (user review) 2026-10-08 13:42:25 -04:00
John O'Keefe 44c0e5640f fix(web): banner highlight-note dialog prefilled; explicit note boundary; pencil on every note row
- BUG: startBannerEdit extracted the note by splitting row.rich — the
  RENDERED HTML — on \n\n, which the concatenated markup never
  contains, so highlight-note dialogs always opened empty. Both row
  kinds carry the raw note on bannerSources; use that lookup.
- Expanded body now splits at the highlight→note boundary: the
  selection paragraph (left border in the row's highlight color), a
  divider + pencil NOTE label, then the note markdown — instead of the
  two texts flowing together.
- The note-indicator pencil (corner badge) now shows on ALL note rows,
  not just highlights with attached notes.

Verified in headless Chromium: badge on note and highlight rows;
expanded highlight shows the bordered selection, NOTE label, and note
markdown; the highlight edit dialog prefills with the note text.
2026-10-08 09:11:47 -04:00
John O'Keefe e7ccd7b222 test(dashboard): align test seed naming with the canonical Title-case set + fix stale expectations
The test helper createDefaultCollectionsForUser carried its own
kebab-case copy of the default system collections — drifted from the
canonical Title-case list the registration seed and
RestoreSystemCollection use. Every suite run against the shared dev DB
re-created a kebab set beside the canonical one (the duplicate sections
on the customize screens); the dev DB is cleaned and the helper now
uses the canonical list.

Two stale expectations corrected while re-running the suite:
TestGetSections_EndToEndFlow asserted the kebab ids (now asserts the
Title-case names — section ids ARE collection names), and
TestGetSections_MissingLibraryID expected a 400 the handler never
implemented — absent library_id is the All Libraries view by design
(now asserts 200 + aggregated sections).
2026-10-07 20:32:36 -04:00
John O'Keefe b1de0da512 feat(web): dashboard All Libraries view saves the global scope
The All Libraries entry in the library switcher could render the
cross-library dashboard but rejected its own settings: the modal
refused to open without a library ('Select a specific library...') and
a save sent library_id: '' (server 400). The server now stores a
global scope (separate table; null/absent library_id, empty string
still 400), so:

- openDashboardSettings allows the All view and fetches prefs without
  the library param (reads the global row; previously a loose NULL
  OR-clause matched an arbitrary library's row).
- saveDashboardSettings sends library_id: null from All Libraries.
- The settings modal gains a scope label (#dashboard-scope-label)
  showing which library — or All Libraries — a save will customize
  (user requirement: always know the scope).

Verified end-to-end in headless Chromium: reorder on All Libraries →
save (null) → reload → modal re-opens with the persisted order and the
All Libraries scope label; per-library save lands in its own row
without touching global (SQL-checked).
2026-10-07 19:51:59 -04:00
John O'Keefe 25d44452c0 feat(dashboard): global (All Libraries) preferences scope
The dashboard's All Libraries view could render sections across every
library but could not persist its own preferences: the PUT required a
UUID library_id, and the prefs GET-without-library_id matched an
arbitrary library's row through a loose NULL OR-clause.

Storage: a dedicated user_dashboard_preferences_global table (one row
per user, user_id PK → users CASCADE) instead of a NULL/sentinel
library_id — chosen over both because (a) the column's FK to libraries
rules out a sentinel UUID without dropping the constraint or minting a
fake library row, and (b) intent becomes structural: only the explicit
global path writes that table, so a buggy or outside-the-API write can
never masquerade as global. System and user collections are
user-scoped, so the order/hidden arrays are library-independent and
valid in this scope.

API: PUT /api/dashboard/preferences binds library_id as *string —
JSON null/absent = global, empty string = 400 (a lost value must stay
loud, not silently clobber the global scope), valid UUID = per-library
(unchanged). GET without library_id now returns the global row instead
of an arbitrary one; the per-library query's dead NULL OR-arm is
removed. GET /sections keeps requiring library_id for per-library
views and aggregates across libraries when absent (unchanged).

Tests: global save/update-dedupe/read round-trip, empty-string 400,
global-vs-per-library independence. Two suite failures are the
handoff's documented pre-existing rot (duplicate system collections in
the dev DB; a sections test expecting a 400 the handler never
implemented) — reproduced and confirmed unrelated to this change.
Live-verified on the dev stack: null save 200 x2 with a single row
updated, empty string 400, global read carries the second save.
2026-10-07 19:46:17 -04:00
John O'Keefe b8db1d109c feat(web): note-attached badge on banner highlight rows — corner stroke glyph
Highlights with an attached note get a small edit-note stroke SVG
(same note-pencil path as the selection popover's Note affordance) in
the card's upper-right corner — themed via var(--text-secondary),
pointer-events: none so taps pass through, header gains pr-8 on those
rows so wrapped text never runs under it. Placement matches the
Android banner's corner badge (user preference: never left of text).
Regenerated annotations_banner_templ.go from the repo root.
2026-10-07 15:30:26 -04:00
John O'Keefe bbecd1e5ec fix(web): rich markdown notes get block structure — paragraphs, quotes, lists, code
Tailwind preflight zeroes p/blockquote/list margins globally, so notes
rendered through renderNoteHtml collapsed flat: blank lines between
paragraphs vanished, blockquotes were indistinguishable from body text,
lists had no bullets/indent, code blocks had no padding. The parser was
always correct — the loss was CSS at the x-html display sites.

One shared scoped block (.note-rich, in @layer components so it
outranks base preflight): p+p margin-top, blockquote bar + indent +
secondary color, list markers + padding, pre/code padding + themed bg,
modest heading sizes, underlined links, table borders, em-based so it
scales with each site's text-xs/text-sm context. Applied at all three
display sites: reader notes-drawer rows (replaces the unused
reader-note-rich hook), the 📝 highlight note line, and the banner's
expanded body.

The regenerated _templ.go files also catch up two stale .templ changes
that had shipped without regeneration (the selection popover's Adjust
button, the banner delete ternary) — the Docker build regenerates at
image-build, so the served site always matched the sources.

Verified live (headless Chromium against the dev stack): .note-rich in
the served stylesheet; p+p computed marginTop 9.6px, blockquote 3px
solid border-left, ul disc + 22.4px indent, pre padded + themed bg.
2026-10-07 08:01:41 -04:00
John O'Keefe ffe690691b fix(web): facedown teardrop handles (standard Android selection fashion) 2026-10-05 19:16:28 -04:00
John O'Keefe 0708a1f414 fix(web): mirrored handle teardrops — end handle points right 2026-10-05 17:29:06 -04:00
John O'Keefe c8e85817aa feat(web): highlight extent adjustment — draggable handles with word/punctuation snap
Objective 9 (web half). The selection popover's edit mode gains an
Adjust button: the overlay's show-annotation event stashes the live DOM
range, start/end teardrop handles render over it (host layer above the
iframe), and pointer drags map into the iframe's caretRangeFromPoint.
Snapping: land next to punctuation → the boundary sits exactly there;
land inside a word → the word's edge in the drag direction; whitespace
trims toward the adjacent word. A live preview repaints through the
overlayer under a scratch key; relocation (page turn) cancels safely.

Release recomputes the range CFI + collapsed-end CFI (view.getCFI) and
saves through the popover's own saveHighlightChanges path (PUT +
remove-old/add-new overlay dance), keeping selection_text and the
anchors in lockstep.

Verified live E2E in Brave: extent extended by one word with the
comma/space snap behavior, saved to the server row, handles cleaned;
all snap paths unit-exercised in-page (word end/start, punctuation
exact on both sides, whitespace trim); the test highlight was restored
to its original extent through the same adjust flow (punctuation-precision
drag back to the comma).
2026-10-05 16:50:27 -04:00
John O'Keefe f45adc6362 fix(web): banner highlight delete; retention wording states the days
The book-detail banner's highlight rows gain Delete (tombstoned
server-side, restorable from the Deleted modal — same as the reader
drawers already offered). The deleted-annotations modal now says the
retention in plain language — 'kept for 30 days' from the
annotation_tombstone_ttl_days tunable (threaded through BookDetail →
NotesHighlightsModal; 1-day singular and retention-disabled variants
handled) — instead of referencing a settings name a reader cannot see.
2026-10-05 12:15:45 -04:00
John O'Keefe 5275f2ba38 feat(web): note editing at the reader slider and book banner; Deleted rename
Objective 8a (web half). Reader notes-drawer rows gain an inline editor
(mirroring the bookmark-rename pattern) that PUTs the new content — the
stored position rides along because the server's note PUT hard-sets the
position column (a probe note proved an omitted position NULLs the
anchor). Book-detail banner rows gain Edit/Delete for location notes
and Edit-note/Add-note for the note ATTACHED to a highlight (full-replace
highlight PUT; empty content clears the note; the highlight itself is
never edited from the banner). The edit dialog is centered with the
markdown caption.

The book-detail 'Notes' button becomes 'Deleted' (badge =
deleted-annotation count) and the modal title becomes 'Deleted
annotations' — that modal only ever showed removed annotations.

Verified live in Brave: drawer inline edit round-trips content and
position; banner note edit, highlight-note edit, and delete (tombstone
appears in the Deleted modal); rename renders with the count badge.
2026-10-05 11:36:14 -04:00
John O'Keefe 0b83db8fce feat(web): book-detail annotations banner — ?loc= reader jumps, single/zip markdown export
Release / build-and-push (push) Successful in 3m9s
Objective 7 (web half). The 'Annotations' button slides a right-hand
banner (annotations_banner.templ + annotations-banner.ts) fed by the
existing per-book GET endpoints: friendly labels (Section N parsed from
the CFI spine / Page N from the PDF envelope / NN% + first-words
fallbacks), capped highlight previews, rich x-html expansion through the
objective-6 markdown pipeline, and per-row 'Open reader here' jumps
riding /readers/:uuid?loc= (consumeLocJump: jump outranks stored
progress for that open; URL stripped before sync can echo it). WEB-ONLY
export: single .md or one-file-per-item zip via fflate — item format
identical both modes ('# friendly label' / '# selection text' headers,
raw markdown bodies, no raw CFIs anywhere).

Verified live in Brave: 12 restored highlights + a markdown note
labeled/expanded correctly; the jump navigated, landed on the right
chapter and consumed ?loc=; export produced the 13-item .md (friendly
headers only) and a valid 13-entry zip. Book-detail's existing
recently-deleted modal stays untouched (banner is additive).
2026-10-04 14:30:56 -04:00
John O'Keefe 4b5e193d9f feat(web): notes render as markdown — markdown-it+DOMPurify pipeline, rich paste
Objective 6 (web half). Storage/sync stay raw markdown (KOReader shows
literal source, accepted). Notes-grade syntax: headings, emphasis/
strikethrough, code, links, lists, blockquotes, GFM tables, reference
links, footnotes (markdown-it-footnote); images/raw HTML/math excluded
(html:false posture + FORBID img/style/form + default safe-scheme URI
check). Display sites: annotations-drawer note rows and highlight note
lines render pre-sanitized HTML (x-html); render-time sanitize only.

Rich paste: both note textareas intercept paste; a text/html clipboard
flavor converts via turndown (gfm tables incl. a headingless-table rule
emitting pipe syntax; images blanked) and inserts at the caret; plain
pastics fall through to the default paste unchanged.

Verified live in Brave: bold/italic/strike/code/lists/quote/table/
footnote-ref/reference-link render; <script> renders inert literal
text with no alert; javascript: hrefs absent from the DOM; paste of
rich HTML lands markdown at the caret; plain pastes untouched.
2026-10-04 13:25:17 -04:00
John O'Keefe b9f797789f feat: registration_enabled admin setting — hides signup surfaces, gates the API
Release / build-and-push (push) Successful in 3m9s
One boolean (Security > Public Registration) switches the whole public
signup lifecycle: front page + login links, the logged-out sidebar's
Create-an-account (via a package-level templates hook so shared page
templates keep their signatures), GET /register -> 302 /login, and an
early 403 on POST /api/auth/register. First-user exception preserved:
zero admins keeps every route and link reachable for bootstrapping —
same 'users exist' reasoning as the setup gate. Admin user creation is
unaffected by design.

Live E2E verified: flag off hides all surfaces and blocks the POST
(403), flag on restores them (201); the admin UI renders the row
automatically.
2026-10-03 23:54:07 -04:00
John O'Keefe 6aaefe210a pin foliate-js b7597d6 — paginator teardown-race guards (v0.5.1+0.5.2) 2026-10-03 23:10:21 -04:00
John O'Keefe fb21d5249c fix(rescan): updateMediaItem re-derives format flags — force rescan reclassifies rows 2026-10-03 22:40:40 -04:00
John O'Keefe b03ed9a2f2 fix(compose): publish db on loopback only — was reachable from any LAN
The unqualified 0.0.0.0 publish put Postgres on every interface, and
Docker delivers published ports through PREROUTING DNAT into the
FORWARD path — ufw's default deny incoming never sees those packets.
Result: the database was reachable from whatever network the laptop
joined (home, guest Wi-Fi, hotel), not just from the host.

Bind to 127.0.0.1/[::1] instead: with no DNAT matching LAN-destined
packets, they fall back to INPUT where the firewall actually applies.
Host-side tools keep working over the loopback publish (both families
bound because localhost may resolve to ::1 first); app↔db and tests↔db
are untouched — they use the db service name on the compose network,
which never traverses iptables on this host (br_netfilter not loaded).

If LAN access to the DB is ever wanted again, revert to an unqualified
publish and rely on DOCKER-USER home-subnet scoping instead of an
open binding.
2026-10-03 21:35:15 -04:00
John O'Keefe 72839921f2 drop DOC and LIT from the pipeline (deliberately unsupported)
.IMAGE  — extensions removed from AllowedExtensions (scan gate),
bookExtensions, MimeTypes and sync/format.go maps: .doc/.lit files
are no longer scanned or indexed. classifyFormatGroup gains the
RTF and PDB reflowable arms that were missing when those rungs
shipped (rows only reclassify on creation). Scanner docs updated.

DOC + LIT have no credible JS tooling (mammoth is docx-only; LIT
needs LZX and its DRM variants are dead) and text-extraction-only
support would misrepresent what the reader can do. Verified: probe
.doc/.lit files watched but never scanned; probe .txt control
scanned; TestClassifyFormatGroup extended and passing.

Docs change granted in-session by the user (extensions + format
docs).
2026-10-03 16:11:04 -04:00
John O'Keefe 25981fc762 pin foliate-js ac699e7 — RTF + PalmDOC support (fork v0.5.0) 2026-10-03 15:41:06 -04:00
John O'Keefe f2629942e4 fix(scanner): classify .docx as reflowable — was landing format_group unknown
.docx is accepted for ingest but the format-group switch had no case
for it, so every DOCX row stored format_group 'unknown' with
is_reflowable=false. Extract the classification switch into a pure
classifyFormatGroup helper with a table test, and classify .docx
reflowable like the other reflowable text formats (.rtf/.doc stay out
until they are readable client-side). Existing rows: dev alice (DOCX)
reclassified by hand; production rows reclassify on rescan.
2026-10-03 14:17:58 -04:00
John O'Keefe ad1ad11fb5 docs(bruno): document the 100k note content limit
Release / build-and-push (push) Successful in 2m49s
Create/Update Media Note request docs still said "1-10000 chars";
update both to match the raised validator cap.
2026-10-02 21:31:18 -04:00
John O'Keefe 66dd6c8096 feat(reader): note length counter and save guards in the reader UI
Oversized notes previously failed only at save time, surfacing the
raw backend validator error to the reader. The reader now handles
the limit inline and keeps oversized input editable:

- Live character counter under both note textareas (annotations
  drawer "Add a note" and the selection popover note editor),
  formatted "12,345/100,000" — muted normally, red once over
- Pasting is never truncated; the full text stays in the textarea
  so the reader can shrink it however they see fit
- The three save actions (Add Note, Highlight with note, Save
  note) disable while over the limit, with a toast fallback in
  addNote / createHighlight / saveHighlightChanges so the guard
  holds even outside the disabled-button path
- noteMaxLength mirrors the server-side cap (100,000, see
  internal/handlers/media.go) so client and server stay linked

Also truncate note rows in the drawer list — highlight rows
already truncated, but a long note previously stretched the
drawer body.

The resize handle needed no work: .reader-note-input already
ships resize: vertical in the compiled CSS; style.css is rebuilt
only for the new disabled:* utility classes.
2026-10-02 21:31:13 -04:00
John O'Keefe 1205526422 feat(notes): raise annotation text limit from 10,000 to 100,000 chars
The 10k validator cap rejected legitimate long-form notes — a
scholarly reading note with quoted passages and footnotes lands
around 10.1k chars and failed at save with the raw validator error.

Raise the cap to 100,000 on all six annotation text fields in the
media handler request structs:

- CreateMediaNoteRequest / UpdateMediaNoteRequest Content
- CreateMediaHighlightRequest / UpdateMediaHighlightRequest NoteText
- CreateMediaBookmarkRequest / UpdateMediaBookmarkRequest Notes

No other layer changes: the media_notes / media_highlights columns
are unbounded TEXT, and the KOReader + websocket sync paths never
had a length cap, so the REST API now matches the rest of the
system instead of being the strictest gate.

New integration test pins the boundary: 50k and 100k-char notes
return 201, 100,001 chars returns 400.
2026-10-02 21:31:03 -04:00
John O'Keefe 29ac420876 chore(deps): bump @bookhoard/foliate-js to 50f56ee — paginator guards for not-yet-loaded section documents 2026-10-02 16:40:05 -04:00
John O'Keefe 5ffa371963 chore(deps): bump @bookhoard/foliate-js to 8e1d5fc — DOCX support via mammoth 2026-10-01 14:30:50 -04:00
John O'Keefe 0be70f0051 docs: drop AZW3 from documented format expectations
AZW3/KF8 is a proprietary legacy Amazon format (the pipeline moved to
KFX in 2015; KDP dropped MOBI-family uploads in 2022) and does not
render in the web reader. The scanner still ingests .azw/.azw3 files
(code unchanged) — they are simply no longer documented as a supported
expectation.
2026-09-30 21:22:44 -04:00
John O'Keefe f07dfbfe74 chore(deps): bump @bookhoard/foliate-js to cd2fbf2 — plain-text (.txt) renderer
makeTextBook: UTF-8 decode, blank-line paragraph split, hard-wrapped
lines joined, HTML-escaped, ~150KB section cap at paragraph boundaries
(Part N TOC). Enables the web reader for .txt (previously ingested but
unreadable client-side). Positions for txt books are percentage-based
(sections get index-derived fake CFIs).
2026-09-30 15:48:55 -04:00
John O'Keefe b4b1deafb8 docs(user): Android app guide — LAN setup and the Android 16+ local-network permission
Android 16+ gates per-app access to local networks behind a runtime
permission; without it the app's LAN logins time out with zero packets
leaving the phone (browser works — it is not per-app-gated). The new
device guide covers the sideload install, first-run server URL, the
permission prompt (and manual re-grant path), and a triage table for
the login screen's error line. Per the user: overlay-VPN addresses are
not a consideration and stay undocumented.
2026-09-29 18:12:57 -04:00
John O'Keefe 217f411f74 fix(auth): GetProfile reads the user row — the JWT context stub has no theme
GetProfile returned the theme from the JWT-claims context stub, which
carries only id/email/username/role — user.Theme was always empty, so
the profile response omitted the field and the app's account-theme
read-back (SERVER sync mode) could never see the stored theme. GetProfile
now loads the full user row by id (h.db.GetUser).
2026-09-28 08:45:01 -04:00
John O'Keefe 4fde2a6d83 feat(auth): GetProfile returns the account theme
The Android app's app-chrome theme (UX pass item 6) reads the account
theme from GET /api/auth/profile and re-applies it at startup in
SERVER sync mode. PUT /api/auth/theme already persisted the value;
this completes the round trip on the profile read.
2026-09-28 08:34:11 -04:00
John O'Keefe 3d88f0b960 feat(search): publisher_filter for the media search endpoint
Adds publisher_filter to SearchMediaItems/SearchMediaItemsUnified
(fuzzy word_similarity against mi.publisher, mirroring genre_filter),
plumbs it through services.SearchParams and the search handler, and
extends the GREATEST relevance ranking to include publisher matches.
Serves the app's author/publisher/genre/tag click-through browses
(UX pass item 5 — publisher was the only facet without a server
filter).
2026-09-27 19:55:58 -04:00
John O'Keefe 6aea6a4e2c fix(sync): countTextCharsBefore multiplied the document by node depth
Release / build-and-push (push) Successful in 2m57s
countTextCharsBefore started its sibling walk at the target node itself
and recursed after counting the current node's subtree, so every
ancestor level re-counted everything accumulated so far — a node at
depth 4 in the single-document 1984 epub reported 2.38M chars before it
in a 589k-char document, producing healed percentages of 4.04 (>1) and
a 500 on the reading_progress percentage check constraint. Every
healed progress save from a fresh client failed; only exact-context
matches (no heal) stored.

The walk now starts at target.PrevSibling: strictly the characters
before the node, per the contract all three callers already assume
(healed book offsets, CRE convert percentage, kepub offsets).

Found by the real-phone validation pass: the phone's first healed
submission 500ed where the emulator's had matched context exactly and
never taken the heal path.
2026-09-26 22:01:35 -04:00
John O'Keefe b10bf3e8c7 docs(api): real progress endpoint contract; remove nonexistent-endpoint docs
The progress documentation described GET/POST /api/progress/:id —
endpoints that do not exist in the router — while the endpoint every
client actually uses (GET/PUT/DELETE /api/media-items/:id/progress) had
no field-level docs at all.

New:
- progress/position-contract.md: the canonical position model — server
  as position authority, the three-tier submission (percentage /
  context_text / epubcfi), ingest verification and healing, the restore
  handles, the OPF spine numbering hazard (canonical CFI spine steps
  include linear="no" items; clients resolve documents by anchor_href
  and land by css_selector + char_offset, never by spine step), the
  offset currencies (UTF-16 at the wire, runes internal), and
  context_text rules.
- progress/get_media_progress.md and update_media_progress.md: the real
  endpoints with full field tables, conditionality of the restore
  handles, the first-page anti-clobber guard, and healed-response
  semantics.
- progress/delete_media_progress.md: the real DELETE route.
- koreader/sync_progress.md and koreader/get_metadata.md rewritten to
  the actual payloads: the plugin sends a single-book array whose
  "epubcfi" field is a CRE xpointer; the metadata response navigates
  via koreader_xpointer (canonical CFI converted back to CRE), with
  page as the canonical locator for fixed-layout books.

Removed: the five files documenting the nonexistent /api/progress/:id
GET/POST/DELETE endpoints. Kept get_progress_history.md (that route
exists). The legacy developer/api-reference.md and the indexed
api/api-reference.md progress sections now match the wire and link the
new docs; the duplicate "Universal Progress" section points at Reading
Progress.
2026-09-26 21:27:44 -04:00
John O'Keefe 8c3273a0fc fix(sync): normalize character offsets to UTF-16 at the wire; refresh book offset on every verified save
Offset currency policy, now explicit: EPUB CFI terminals, CRE text()
offsets and the served char_offset handle are UTF-16 code units (the
EPUB CFI spec, and what foliate/readium/KOReader/Kobo clients actually
observe), while internal arithmetic — the book-wide character_offset
column and percentage fractions — stays rune-based, consistent with
TotalCharacters. For all-BMP books the currencies are identical, so no
stored value changes; astral-plane text (emoji, rare CJK) no longer
drifts.

Boundaries converted: resolveCFIToNode interprets incoming CFI terminal
offsets as UTF-16; textNodeAtUTF16Offset (née textNodeAtRuneOffset)
interprets CRE text() offsets as UTF-16; buildCFI and buildCREXPointer
emit UTF-16 terminals; blockCharOffset (the served char_offset) is
UTF-16.

Also fixes two character_offset column defects: heals wrote a BLOCK-
relative offset into the book-wide column, and verified-but-unhealed
saves (e.g. KOReader pushes) never refreshed it, leaving it stale
behind the anchor. VerifyProgressAnchor now returns the verified book-
wide rune offset and SaveProgress refreshes the column on every
verified save.

Tests: astral currency round trip (offset after an emoji must shift by
one unit between currencies, in both heal and exact-verify directions)
and book-offset ordering. The cmd/server/tests integration harness
failures under docker (library folder 400 during setup) reproduce on
the pre-change tree and are unrelated.
2026-09-26 20:18:48 -04:00
John O'Keefe aec226af1a feat(sync): server-side position authority — verify/heal progress anchors
Progress submissions now carry (percentage, context_text, epubcfi) and
the server becomes the position authority:

- VerifyProgressAnchor resolves the submitted standard CFI against the
  book's own XHTML, extracts the text at the anchor, and cross-checks it
  with the submitted context_text. A mismatch heals the anchor by text
  search (percentage disambiguates repeats) instead of storing a bad
  position.
- The anchor's block element is derived as a cssSelector plus a block-
  relative character offset, and served on progress GET alongside the
  anchor document's href — readium-native handles that let clients
  re-open a book without parsing CFIs themselves.
- context_text-only submissions (no CFI — the dumb-client tier) are
  anchored structurally from the context text.

Motivation: cross-client progress sync (web foliate CFIs, KOReader CRE
xpointers, readium-native apps) previously trusted each client's own
locator math; the app's EPUB restore drifted ±pages because readium's
paginator does not lay out far-from-viewport columns and the foliate-
ported CFI walk ran against readium's mutated WebView DOM. Server-side
verification heals both classes at ingest.
2026-09-26 14:43:34 -04:00
157 changed files with 9618 additions and 4140 deletions

No files matched your search

+3
View File
@@ -79,3 +79,6 @@ web/static/vendor/
VALID_TOKEN
fresh_token.txt
session-*.md
# Local prod-export scratch: personal annotation/reading data, never commit
/export/
+73
View File
@@ -0,0 +1,73 @@
# AGENTS.md — Bookhoard server
Standing rules for any agent or developer working in this repo.
Project-wide context lives in the sibling repos' AGENTS.md files
(../bookhoard-app/AGENTS.md is the Android client's).
## Library visibility (family-lens rule) — ALWAYS apply
Per-user library visibility (`library_visibility` table, default-ALLOW:
every user sees every library unless an admin hides one) must be
enforced on **every endpoint that returns media metadata or content**.
It is NOT automatic — enforcement is per-handler.
- **New endpoints that list/filter media or series** MUST filter by the
requesting user's visible-library set, and MUST reject an explicit
`library_id` outside that set with 403 (never silently pass).
- Helpers (do not hand-roll):
- `handlers`: `(*MediaHandler).visibleLibrarySet` /
`(*SeriesHandler).visibleLibrarySet` — membership map + id slice.
- `router`: `visibleLibraryIDsFor` — same for SSR routes.
- `resolveLibrary` (helpers.go) only honors requested library ids
inside the visible set.
- SQL pattern: `AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR
library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))` — see
`ListMediaItems`, `GetDistinctSeries`, `SearchMediaItems`.
- Content endpoints (file bytes, page images) additionally gate per item
like `ReaderHandler.ensureItemVisible` / the download endpoint.
- Tests must pin visibility: hidden library → 403 on the endpoint, and
absent from unscoped aggregates.
History: 2026-10-13 (`431fec0`) the series/media listing surfaces
leaked hidden libraries to any authenticated account — series endpoints
were name-only across ALL libraries. Fixed; do not regress.
## Tests
- Use `setupTestServer()` from `cmd/server/tests/test_helpers_test.go`
(see the Conventions section below for the pool/cleanup rules).
- Integration tests run against the SHARED dev database: never write
destructive SQL without preserving the dev rows (libraries Manga/
Comics/Ebooks, testuser's annotations and progress are real data).
- Test-created users must use `@tests.bookhoard.internal` or
`@example.com` emails and "test"-named collections/libraries so the
harness cleanup can find them.
- **NEVER rename or edit the shared admin (`testuser`) in tests** — a
renamed admin escapes the cleanup preserve-list and cascades real
data (this wiped the dev annotations once; see user_test.go
TestUpdateProfileSelfEdit for the dedicated-user pattern).
## Timezones
- The server owns display conversion: clients send instants (RFC3339),
the server renders in the requesting user's timezone.
- Use `utils.FormatTimeInTimezone` / `utils.UserLocation` — never
`time.Now().Format(...)` bare (that is server-UTC).
- Analytics day-buckets must bucket in the user's timezone
(`CreatedAt.In(loc)`), and date-range end bounds must be end-of-day.
## Conventions (absorbed from the retired PROJECT_GUIDELINES.md —
## only the rules that still matched reality)
- **Runtime is Docker** (Makefile auto-detects docker, falls back to
podman). The old "podman only" rule is dead.
- **The server binary is only built inside the Docker build**
(`make rebuild-app`); host-side Go builds are for tests/vet only.
- **Schema changes go in place** — edit `database/schema/schema.sql`,
never create migration files, until a release splits them.
- **Business logic lives in services**, not handlers; handlers parse
requests and call services (all DB access goes through services).
- **Integration tests**: one `setupTestServer()` per test function;
test pools use `max_conns=1`; never create a `pgxpool` per subtest
(connection exhaustion); use `t.Cleanup()`-based helpers; table-driven
subtests over duplicate functions.
+1 -1
View File
@@ -1065,7 +1065,7 @@ If there's a section about documentation features, mention the new search functi
## Git Commit Strategy
Follow PROJECT_GUIDELINES.md - make multiple logical commits:
Follow AGENTS.md conventions - make multiple logical commits:
1. **Backend implementation** (search.go, http_handler.go, router changes)
2. **Unit tests** (search_test.go)
+1 -10
View File
@@ -1,4 +1,4 @@
.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick release
.PHONY: help test test-integration test-all rebuild-app rebuild-app-force rebuild rebuild-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down release
# Include .env file for environment variables (single source of truth)
# Ignore if .env doesn't exist yet
@@ -42,8 +42,6 @@ help:
@echo " make test-env-down - Stop test environment"
@echo ""
@echo "Verification:"
@echo " make verify-guidelines - Run comprehensive guidelines check"
@echo " make verify-quick - Run quick guidelines check"
@echo ""
@echo "Release:"
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
@@ -153,14 +151,7 @@ test-env-up:
test-env-down:
$(COMPOSE) down -v
# Verify project guidelines compliance
verify-guidelines:
@echo "Running comprehensive project guidelines verification..."
@./scripts/verify-guidelines.sh
verify-quick:
@echo "Running quick project guidelines verification..."
@./scripts/verify-quick.sh
# Create an annotated version tag carrying auto-generated release notes (git-cliff)
# and push it. The tag push triggers .gitea/workflows/release.yml, which builds the
-480
View File
@@ -1,480 +0,0 @@
# Combined Project Guidelines for Bookhoard
## 🚨 CRITICAL PROHIBITIONS (Never violate these)
### Backend & Database
- ❌ **NEVER modify backend code when working on frontend-only tasks*
- ❌ **NEVER modify database schema** unless explicitly instructed for full-stack changes
- ❌ **NEVER use Docker** - use Podman only
- ❌ **NEVER build server binaries locally** - all builds through Dockerfile/docker-compose
- ❌ **NEVER create new migration files** - merge changes into current one until release
- ❌ **NEVER use `git checkout` on schema files** without checking what will be lost
- ❌ **NEVER break existing functionality** unless explicitly instructed
- ❌ **NEVER duplicate business logic** - keep logic in services, not handlers
- ❌ **NEVER bypass service layer** - all database operations must go through services
### Testing
- ✅ **ALWAYS use `setupTestServer()` helper from `cmd/server/tests/test_helpers_test.go`*
- ✅ **Share one test setup across all subtests** - call `setupTestServer()` once at test function level, not per subtest
- ✅ **Prefer table-driven tests** - use `t.Run()` with test cases instead of duplicate test functions
- ✅ **Configure database pools efficiently** - use `max_conns=1` for test pools (via `pgxpool.ParseConfig()`) to prevent connection exhaustion
- ❌ **NEVER create separate `pgxpool` per test** - each pool creates 4 connections by default; 78 tests = 312 potential connections > PostgreSQL's 100 limit
- ❌ **NEVER call `setupTestServer()` in loops or within subtests** - creates unnecessary database pools and exhausts connections
- ✅ **DO verify tests pass** - run full test suite before completing work
- ✅ **Use `t.Cleanup()` properly** - the `TestServerSetup` pattern automatically handles cleanup via `t.Cleanup()`
### Frontend & Styling
- ❌ **NEVER modify backend/API for frontend features without user confirmation*
- ❌ **NEVER use custom CSS** - TailwindCSS classes only
- **⚠️ EXCEPTION**: `templates/error.templ` may have inline CSS because error pages must work when main app fails (404, server errors, CSS fails to load)
- ❌ **NEVER use JavaScript** - convert all to TypeScript
- **⚠️ EXCEPTION**: Inline JS function calls in HTML attributes (e.g., `onclick="myFunction()"`) are acceptable for simple interactions
- ❌ **NEVER use Object-Oriented Programming** (no classes, inheritance, or this-capture)
- ✅ **DO use procedural/imperative style** as your default
- ✅ **DO borrow functional techniques** when they simplify code
- ✅ **DO avoid ideological purity** - the best paradigm is the one that fits the problem
- ❌ **NEVER add new Dockerfiles without user confirmation*
- ❌ **NEVER fetch initial data via AJAX on page load** - use server-side rendering instead
- ❌ **NEVER break progressive enhancement** - pages must work without JavaScript
**Note:** Go methods in the backend are fine and encouraged. This guideline applies to TypeScript/JavaScript frontend code only.
### General
- ❌ **NEVER skip pre-commit hooks** unless explicitly requested
- ❌ **NEVER force push to main/master** branches
- ❌ **NEVER commit files with secrets** (.env, credentials.json, etc.)
- ❌ **NEVER make assumptions** - ask clarifying questions when uncertain
- ❌ **NEVER delete code without reading full context first** (minimum 20 lines before/after)
- ❌ **NEVER make cascading fix-up edits without git diff review*
- After compilation error: STOP, review `git diff`, understand full impact
- Use revert/reapply pattern instead of blind fixes
- ❌ **NEVER skip post-edit verification** - must compile after each file edit
- ❌ **NEVER run git commands concurrently** - always run sequentially:
- Run `git add <files>` and wait for completion
- Run `git commit -m "<message>"` and wait for completion
- Run `git push` and wait for completion
- Never use `&&` to chain git commands together
- ✅ **Make good organized git commits for the entire project (not just what you changed) and push - run git add, commit, push sequentially as separate commands, no need to repeatedly check status*
- ✅ **Indentation is always 2 spaces unless the language prohibits it*
### Cascading Fix-up Pattern (PROHIBITED)
**WHAT NOT TO DO** - This caused critical bugs:
```go
// ❌ WRONG: Blindly making fixes after compilation error
Edit 1: Delete deprecated code
[Compilation error: undefined Register]
Edit 2: Try to fix error (over-broad deletion)
[More errors: undefined Login, GetProfile, etc.]
Edit 3: Try to fix again (worse damage)
[Even more errors: major functionality broken]
```
**CORRECT APPROACH**:
```go
// ✅ CORRECT: Stop, understand, then fix deliberately
Edit 1: Delete deprecated code
[Compilation error: undefined Register]
STOP → Review git diff → Understand Register was accidentally deleted
RESTORE → Get exact Register function from git history
VERIFY → Compile successfully
```
**Key Principle**: When compilation errors occur after edits:
1. STOP - Don't make more edits
2. ANALYZE - Use `git diff` to understand what was changed
3. RECOVER - Restore what was accidentally deleted/broken
4. VERIFY - Compile and test before proceeding
---
## 🎯 CONTEXT-SPECIFIC RULES
### When Working on Frontend-Only Tasks
- **DO NOT touch backend code** - handlers, services, database layer
- **DO NOT modify API routes** - use existing endpoints only
- **DO NOT change database schema** - work with existing structure
- **If backend change seems necessary**:
1. Identify the required change
2. Explain why you need it
3. Provide impact analysis
4. **ASK FOR USER CONFIRMATION before proceeding*
### When Working on Full-Stack Tasks
- Backend changes are allowed when explicitly part of the task
- Still follow all database protocols (atomic changes, validation, etc.)
- **If modifying database schema:** Update local database after schema.sql changes (see Database Operations section)
- Still use Podman for all builds
- Still include Bruno requests for API changes
---
## ✅ MANDATORY REQUIREMENTS
### Database Operations (Full-Stack Tasks Only)
- ✅ Follow **pgx v5 standards** for all database operations
- ✅ Treat schema changes as **ATOMIC** - complete success or complete rejection
- ✅ **⚠️ CRITICAL: This is a pre-production application (NO production deployments exist)*
- When `database/schema/schema.sql` is updated, local databases must be updated
- **Option 1 (Recommended):** Recreate database with fresh schema:
```bash
podman compose down -v # Delete volumes (WARNING: loses all data)
podman compose up -d # Start fresh with new schema
```
- **Option 2:** Manually apply schema changes to existing database using psql
- **DO NOT create migration files** - no legacy schema support needed
- ✅ Use **pre-change checklist**: read schema → identify columns → plan changes → verify → read back
- ✅ **Post-change validation**: ensure schema.sql, models.go, and queries.sql are in sync
### Build & Deployment
- ✅ Use **Podman** exclusively (not Docker)
- ✅ All builds through existing **Dockerfile** and **docker-compose.yml*
- ✅ Stop building server binaries - everything goes through containers
### API Changes (Full-Stack Tasks Only)
- ✅ Include **Bruno OpenCollection YAML requests** with all API documentation
- ✅ Integration Tests (cmd/server/tests) must be **comprehensive and cover three contexts**: no user, user, and admin
- ✅ Maintain backward compatibility for mobile apps and external consumers
### Frontend & Styling
- ✅ Always use **TailwindCSS classes** for all styling
- ✅ Convert all JavaScript to **TypeScript**
- ✅ **Never use Object-Oriented Programming** (no classes, inheritance, or this-capture)
- ✅ **Use procedural/imperative style** as your default
- ✅ **Borrow functional techniques** when they simplify code
- ✅ **Avoid ideological purity** - the best paradigm is the one that fits the problem
- ✅ **Extract domain concepts/types only when clearly beneficial** - apply YAGNI, avoid over-engineering
- ✅ **Render initial data server-side** in Go templates for fast page loads
- ✅ **Use JavaScript/HTMX for CRUD operations** (create, update, delete)
- ✅ **Ensure progressive enhancement** - pages work without JavaScript
### Service Layer Architecture
- ✅ **All business logic in services** - never in handlers
- ✅ **Services must be reusable** by both SSR handlers and API endpoints
- ✅ **Database operations through services only** - never direct from handlers
- ✅ **When adding features**: Add service logic → Create API endpoint → Use SSR for initial render → Use JS for updates
### Code Organization
- ✅ Minimize project structure changes
- ✅ Place new files in **contextually appropriate directories*
- ✅ Follow **KISS**, **DRY**, and **YAGNI** principles
- ✅ Use **multiple, logical git commits** with clear messages
- ❌ **NEVER duplicate types between handlers and templates** - define data types in handlers, reuse directly in templates
- ✅ **DO share handler types with templates** - templates should use handlers.CollectionData, handlers.BookInfo, etc. directly
- ❌ **NEVER create parallel type systems** - no templates.XxxData types for data that originates from handlers
- ✅ **DO enhance handler types with template-specific fields** when needed (e.g., add Icon, FormattedDate fields to handlers structs)
- ✅ **DO keep template-only types in templates package** - PageData, UnsafeHTML, and template-specific utilities are appropriate
- ❌ **NEVER create conversion helper functions** to map between handler and template types - use handler types directly
### Configuration & Environment
- ✅ If **.env is missing**, auto-generate secure values
- ✅ Never commit secrets to repository
### Code Modification Safety
- ✅ **Post-Edit Verification (MANDATORY for ALL file modifications)**:
- Run `go build` for affected packages immediately after each edit
- Review `git diff filename` to verify only intended changes
- Validate functionality still works as expected
- Never proceed to next file until current edit is verified
- ✅ **Backup Before Large Changes**:
- Create a stash: `git stash push -m "Pre-cleanup snapshot"` before removing >50 lines
- Or create a backup branch: `git branch backup-before-cleanup`
- This allows instant recovery if mistakes occur
- ✅ **Large Deletion Safety Pattern**:
- Read at least 20 lines before/after deletion target
- Include unique identifiers in match (function signatures, specific comments)
- Use narrow matches - avoid generic patterns
- Verify line numbers match intended section
### Documentation
**Documentation Structure** (updated with full docs system):
- ✅ **README.md** - Project overview, quick start, and setup instructions only
- ✅ **docs/** - Comprehensive documentation system with search
- ✅ **docs/developer/api/** - API reference documentation (split by endpoint/category)
- ✅ **docs/user/** - User-facing features, guides, and workflows
- ✅ **docs/user/devices/** - Device setup guides (KOBO, KOReader, etc.)
- ✅ **docs/contributing/** - Development and contribution guides
**Where to document changes**:
| Change Type | Location | Examples |
| ----------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------- |
| **User-facing features** | `docs/user/` | New features, UI changes, workflows |
| **API endpoints** | `docs/developer/api/<category>/<endpoint>.md` | New endpoints, modified responses, authentication changes |
| **API behavior** | Update existing `docs/developer/api/` files | Parameter changes, error codes, rate limits |
| **Device setup** | `docs/user/devices/` | New device support, setup instructions |
| **Development** | `docs/contributing/` | Build changes, architecture decisions |
| **Quick start/setup** | `README.md` | Installation, environment setup, first-run |
| **Breaking changes** | Both `README.md` and relevant `docs/` | Migration guides, deprecation notices |
| **Bug fixes** | Update relevant `docs/` only if user-visible | Clarifications, troubleshooting additions |
| **Bruno OpenCollection YAML requests** | `.yml` files in bruno folder in appropriate folder/sub-folder | API contract testing, examples |
**Documentation Update Workflow**:
1. **Identify the audience** (end users, developers, API consumers)
2. **Choose appropriate location** based on table above
3. **Update documentation** before or with code changes
4. **Verify documentation renders** at `/docs` endpoint
5. **Test search** finds new/updated content
6. **For API changes**: Update both `docs/developer/api/` files, integration test files (cmd/server/tests) AND Bruno OpenCollection YAML `.yml` files
7. **Commit separately** with clear message: `docs: <description>`
**When in doubt**:
- End-user visible → `docs/user/`
- API reference → `docs/developer/api/`
- Setup/onboarding → `README.md`
- Development related → `docs/contributing/`
### Process & Continuity
- ✅ If mid-task and receive "no response", **continue the task*
- ✅ Verify no regressions before modifying/removing code
---
## 🔧 TECHNICAL STANDARDS
### Backend Stack
- **Language**: Go 1.25+
- **Database**: PostgreSQL 15+ with **pgx v5 driver** only
- **Authentication**: JWT tokens with bcrypt password hashing
- **Architecture**: Service layer pattern (handlers → services → database)
### Frontend Stack
- **Rendering**: SSR-first - Go templates render initial page with all data
- **Interactivity**: Alpine.js for UI state (modals, dropdowns, transitions)
- **Dynamic Updates**: HTMX for CRUD operations (no full page reloads)
- **Styling**: TailwindCSS (no custom CSS)
- **Language**: TypeScript (no JavaScript)
- **Patterns**: Procedural/imperative with functional techniques where helpful (no OOP)
### SSR-First Principles
- ❌ **NEVER fetch initial data via AJAX** - server renders complete page
- ❌ **NEVER fetch data in Alpine x-init** - data already SSR'd
- ✅ **Use Alpine.js only for UI state** - modal visibility, dropdown toggles
- ✅ **Use HTMX for dynamic operations** - form submissions, partial page updates
- ✅ **x-init is for setup only** - event listeners, not data fetching
- ✅ **Data fetching happens after user actions** - not on page load
### Page Type Classifications
1. **Type 1: 80% SSR (most pages)**
- Backend provides all initial data
- Alpine handles modals/dropdowns only
- x-init NEVER fetches data
2. **Type 2: SSR + Interactive (dashboard, bookshelf)**
- Backend provides initial data
- Alpine handles interactivity (drag-drop, filtering)
- x-init ONLY sets up event listeners
3. **Type 3: 80% TypeScript (analytics, complex dashboards)**
- Some client-side data fetching acceptable
- Still prefer SSR when possible
### Containerization
- **Runtime**: Podman (not Docker)
- **Build**: Existing Dockerfile and docker-compose.yml only
- **No local builds** allowed
---
## 🔄 ERROR RECOVERY PROTOCOL
When code modification mistakes occur (deleted wrong code, broke compilation, etc.):
### Immediate Actions
1. **STOP** - Don't make more edits
2. **ASSESS** - What was deleted? Is it critical?
3. **REVIEW** - Run `git diff` to see exact changes
4. **RESTORE** - Choose appropriate recovery method:
- **Recent mistake**: `git checkout -- filename`
- **Complex restoration**: Use `git show HEAD:filename` to recover deleted code
- **Multiple files**: `git reset HEAD~1` (if safe)
5. **VERIFY** - Compile and test restored code
6. **DOCUMENT** - Note what went wrong for future reference
### Recovery Examples
```bash
# Recover a deleted function from original file
git show HEAD:internal/handlers/auth.go | sed -n '70,275p' > recovery.txt
# Revert entire file to original state
git checkout HEAD -- internal/handlers/auth.go
# Use git stash to save current state before rollback
git stash push -m "Broken state before fix"
git checkout -- internal/handlers/auth.go
```
### Prevention (Learn From Mistakes)
- Why did the mistake happen?
- Was it too-broad matching?
- Was it insufficient context reading?
- Was it cascading fix-up attempts?
- Update guidelines to prevent recurrence
---
## 📋 WORKFLOW CHECKLISTS
### Before Making Frontend-Only Changes
- [ ] Identify if backend modification could make implementation simpler
- [ ] Plan to use existing API endpoints only
- [ ] If backend change seems necessary, prepare confirmation request:
- Required change description
- Why it would help
- Impact analysis
- Alternative approaches considered
- [ ] Plan git commit structure (multiple logical commits)
- [ ] **Identify documentation location** (see Documentation section):
- [ ] User-facing feature → `docs/user/`
- [ ] UI/workflow changes → `docs/user/`
- [ ] Setup instructions → `README.md`
### Before Making Full-Stack Changes
- [ ] Read current schema completely (if database changes)
- [ ] Identify all columns that must be preserved
- [ ] Plan exact changes needed
- [ ] Verify Podman will be used for builds
- [ ] Plan git commit structure (multiple logical commits)
- [ ] **Identify documentation location**:
- [ ] API changes → `docs/developer/api/<category>/`
- [ ] New endpoints → Create new `.md` file in `docs/developer/api/`
- [ ] API behavior → Update existing `docs/developer/api/` files
- [ ] Breaking changes → Both `README.md` + relevant `docs/`
- [ ] Bruno OpenCollection YAML `.yml` files → Update/create alongside API changes
- [ ] Integration tests (cmd/server/tests) → Update/create alongside API changes
### During Schema Changes (Full-Stack Only)
- [ ] Read current schema completely
- [ ] Identify all columns that must be preserved
- [ ] Plan exact changes needed
- [ ] Set up verification step
- [ ] Make intended changes
- [ ] Immediately verify by reading back modified sections
- [ ] Confirm ALL expected columns are present
- [ ] Verify schema.sql, models.go, and queries.sql are in sync
- [ ] **Update local database** (choose ONE):
- [ ] **Option 1 - Recreate database** (recommended, loses data):
```bash
podman compose down -v # Delete all volumes
podman compose up -d # Start with fresh schema
```
- [ ] **Option 2 - Manual SQL migration** (preserves data):
```bash
podman exec bookhoard_db psql -U postgres -d bookhoard -c "YOUR SQL HERE"
```
- [ ] Verify database has new schema (check column types, indexes, etc.)
### After API Changes
- [ ] Create/update Bruno OpenCollection YAML requests
- [ ] Create/update Integration tests (cmd/server/tests)
- [ ] Test with no user context
- [ ] Test with regular user context
- [ ] Test with admin context
- [ ] Verify backward compatibility
### Before Committing
- [ ] **Run verification script**: `bash scripts/verify-guidelines.sh`
- [ ] **Fix any errors** - verification must pass (0 errors) to commit
- [ ] **Note warnings** - informational only, do not auto-fix
- [ ] Run tests: `go test ./... -v`
- [ ] Run lint/typecheck if available
- [ ] Ensure no secrets in changes
- [ ] Verify logical commit structure
- [ ] **Update documentation** (see Documentation section):
- [ ] User-facing changes → `docs/user/`
- [ ] API changes → `docs/developer/api/` + Integration tests + Bruno OpenCollection YAML `.yml` files
- [ ] Setup/onboarding → `README.md`
- [ ] Development changes → `docs/contributing/`
- [ ] **Verify docs render** at `/docs` endpoint
- [ ] **Test docs search** finds new content
### Error Recovery Protocol (If Code Mistakes Occur)
- [ ] **Stop immediately** - don't make more edits
- [ ] **Assess impact**: What was deleted? Is it critical?
- [ ] **Review git diff**: See exact changes made
- [ ] **Restore strategy**:
- If recent mistake: `git checkout -- filename`
- If complex: Reconstruct from git diff using `git show HEAD:filename`
- [ ] **Verify recovery**: Compile and test restored code
- [ ] **Document mistake**: Note what went wrong for future reference
### Phase Completion Verification (Before Declaring "Complete")
- [ ] All target code is removed/intact as intended
- [ ] No unintended code was deleted
- [ ] All affected files compile successfully
- [ ] Run `go build ./...` for entire project
- [ ] **Run verification script**: `bash scripts/verify-guidelines.sh` - must pass (0 errors)
- [ ] No critical functionality was broken
- [ ] Git diff shows only intended changes
- [ ] Review all modified files with `git diff --stat`
---
## 🏗 ARCHITECTURAL PATTERNS
### Current: SSR-First with Alpine.js + HTMX
```
Browser → Go template (SSR with all data) → Display instantly
↓
Alpine.js for UI state (modals, dropdowns)
↓
HTMX for CRUD (forms, updates)
↓
Shared service layer (Go)
```
**Layer Responsibilities:**
| Layer | Responsibility |
|-------|---------------|
| **Go Template** | SSR initial page with real data |
| **Alpine.js** | UI state only (x-data, x-show, transitions) |
| **HTMX** | Dynamic updates without page reload |
| **TypeScript** | Pure business logic (API calls, data processing) |
**See also:** `SSR_FIRST_ALPINE_GUIDE.md` and `ALPINE_COMPLETION_GUIDE.md`
Ultimately, whenever you are unsure just ask for confirmation.
+26
View File
@@ -127,6 +127,32 @@ The first user to register automatically becomes an admin.
---
## 💾 Network Storage (NAS / NFS / SMB)
Libraries on network mounts are supported for everyday use — serving,
reading, progress, and annotation sync all work over the mount — with a
few behaviors worth knowing:
- **Outage hysteresis.** When the mount drops, files miss one scan
(marked missing, hidden) then a second (archived, still recoverable).
When the mount returns — by path or by identical content elsewhere —
everything is restored automatically: annotations, progress, and
covers included.
- **Retention purge.** Archived items older than `ARCHIVE_RETENTION_DAYS`
(default **90**, `0` = never auto-purge) are deleted for good by
library scans, annotations included. A NAS offline longer than the
retention window loses those books' metadata on return.
- **Deleting while the storage is down is refused.** Deleting a book
cascades its annotations, so deletes are rejected with a clear error
while the item's library storage is unreachable. Archive the item
instead, or retry when the mount is back.
- **Watch mode does not fire on network mounts** (inotify doesn't work
over NFS/SMB). The periodic scan is the change detector.
- **Latency** — folder reachability checks are cached ~30s; large
libraries on slow mounts will still feel slower than local disk.
---
## 🛠 Tech Stack
- **Backend**: Go 1.25+ with Echo framework
+1 -1
View File
@@ -27,7 +27,7 @@ docs: |-
- `id` (string): Media item ID
**Request Body:**
- `content` (string): Note content (required, 1-10000 chars)
- `content` (string): Note content (required, 1-100000 chars)
- `position` (string): Optional position reference (max 100 chars)
**Response:**
+1 -1
View File
@@ -28,7 +28,7 @@ docs: |-
- `noteId` (string): Note ID
**Request Body:**
- `content` (string): Updated note content (required, 1-10000 chars)
- `content` (string): Updated note content (required, 1-100000 chars)
- `position` (string): Updated position reference (optional, max 100 chars)
**Response:**
+18
View File
@@ -10,6 +10,7 @@ import (
"bookhoard/internal/router"
"bookhoard/internal/services"
"bookhoard/internal/sync"
"bookhoard/templates"
"context"
"log"
"time"
@@ -56,6 +57,20 @@ func main() {
if err := registry.Load(ctx); err != nil {
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
}
// Public signup surfaces follow the registration_enabled flag, with the
// first-user exception: an unconfigured server (zero admins) keeps its
// signup links and registration route reachable for bootstrapping.
templates.SetSignupVisibility(func() bool {
if registry.RegistrationEnabled() {
return true
}
count, err := queries.CountAdmins(context.Background())
if err != nil {
return true // fail open, matching setupstatus
}
return count == 0
})
// Wire the registry into the package-level password validator so live
// rule changes apply to the echo struct-tag validator and ValidatePassword.
middleware.SetDefaultPasswordSettings(registry)
@@ -121,6 +136,9 @@ func main() {
// Create library service
libraryService := services.NewLibraryService(queries)
// The progress service verifies submitted anchors against the book
// itself — it needs to resolve library-relative file paths.
progressService.SetMediaPathResolver(libraryService)
// Sync Go AllowedExtensions into DB so API clients see correct extensions
libraryService.SyncAllowedExtensions(context.Background())
@@ -140,7 +140,6 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
body := readBody(resp)
// Verify reading direction badge is displayed
assert.Contains(t, body, "📖", "Should display reading direction icon")
assert.Contains(t, body, "RTL", "Should display RTL reading direction")
})
@@ -263,7 +262,8 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
assert.Contains(t, body, "📚", "Should display story arc icon")
// The redesigned badge renders the arc NAME in a text badge
// (no 📚 icon) — assert the name.
assert.Contains(t, body, "The Dark Phoenix Saga", "Should display story arc name")
})
@@ -508,7 +508,8 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
// Verify all manga-specific fields are displayed
// The redesigned badge (07578e0) renders the direction text chip
// without the 📖 emoji — assert the chip text.
assert.Contains(t, body, "RTL", "Should display RTL reading direction")
assert.Contains(t, body, "Teen", "Should display age rating")
assert.Contains(t, body, "8.5 / 10", "Should display community rating")
@@ -619,9 +620,11 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
assert.Contains(t, body, "Everyone", "Should display age rating")
assert.Contains(t, body, "goodreads.com", "Should display web URL")
// Verify comic-specific fields are NOT displayed
assert.NotContains(t, body, "Manga Type", "Should not display manga type for ebook")
assert.NotContains(t, body, "Scan Info", "Should not display scan info for ebook")
// Verify comic-specific fields are NOT displayed. The page body
// always contains the admin edit modal's "Manga Type"/"Scan Info"
// LABELS — assert the metadata VALUES (what the display block
// renders) are absent instead.
assert.NotContains(t, body, "yes and right to left", "Should not display manga type value for ebook")
assert.NotContains(t, body, "B&W", "Should not display B&W badge for ebook")
})
@@ -682,9 +685,11 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
// Page should still render successfully
assert.Contains(t, body, "Minimal Comic", "Should display title")
// Use "Community Rating:" with colon to avoid matching the HTML comment
// The admin edit modal always carries "Manga Type" as a form
// LABEL — the display block would render the capitalized VALUE,
// which is absent here.
assert.NotContains(t, body, "Community Rating:", "Should not show community rating when not set")
assert.NotContains(t, body, "Manga Type", "Should not show manga type when unknown")
assert.NotContains(t, body, "yes and right to left", "Should not show a manga type value when unknown")
})
}
+275 -8
View File
@@ -69,15 +69,16 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
assert.IsType(s.T(), float64(0), section["priority"], "priority should be number")
}
// Verify all system collections exist
assert.Contains(s.T(), sectionMap, "continue-reading")
assert.Contains(s.T(), sectionMap, "recently-added")
assert.Contains(s.T(), sectionMap, "recently-read")
assert.Contains(s.T(), sectionMap, "not-started")
assert.Contains(s.T(), sectionMap, "continue-series")
// Verify all system collections exist (names are the section ids;
// the canonical seed names are Title-case).
assert.Contains(s.T(), sectionMap, "Continue Reading")
assert.Contains(s.T(), sectionMap, "Recently Added")
assert.Contains(s.T(), sectionMap, "Recently Read")
assert.Contains(s.T(), sectionMap, "Not Started")
assert.Contains(s.T(), sectionMap, "Continue Series")
// Verify continue-reading is a system collection
continueReading := sectionMap["continue-reading"]
continueReading := sectionMap["Continue Reading"]
assert.True(s.T(), continueReading["is_system"].(bool), "continue-reading should be system collection")
assert.Equal(s.T(), "📖", continueReading["icon"], "icon should match")
}
@@ -85,6 +86,8 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() {
token := s.setup.Token
// No library_id = the All Libraries view: the endpoint aggregates
// sections across every library (200), it does not 400.
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/sections", nil)
req.Header.Set("Authorization", "Bearer "+token)
@@ -95,7 +98,14 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(s.T(), err)
sections, ok := response["sections"].([]interface{})
require.True(s.T(), ok, "sections should be an array")
assert.NotEmpty(s.T(), sections, "All Libraries view should aggregate sections")
}
func (s *DashboardIntegrationTestSuite) TestGetSections_InvalidLibraryID() {
@@ -258,6 +268,263 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames()
}
}
func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_GlobalScope() {
token := s.setup.Token
// Absent library_id (JSON null after omit) = the global All Libraries
// scope: stored in user_dashboard_preferences_global, one row per user.
reqBody := map[string]interface{}{
"hidden_collections": []string{"not-started"},
"collection_order": []string{"continue-reading", "recently-added"},
"items_per_section": 15,
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Second global save with different values: must UPDATE the same row
// (the old ON CONFLICT (user_id, library_id) never fired for NULLs and
// would have minted a duplicate per save).
reqBody["items_per_section"] = 25
body, _ = json.Marshal(reqBody)
req, _ = http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Reading prefs without library_id returns the global row (not some
// arbitrary library's).
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var prefs map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&prefs)
require.NoError(s.T(), err)
assert.Equal(s.T(), float64(25), prefs["items_per_section"], "global row should carry the second save")
}
func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_EmptyLibraryIDRejected() {
token := s.setup.Token
// Empty string is a LOST value (a client bug), never a scope — must
// stay a loud 400 rather than silently writing the global scope.
reqBody := map[string]interface{}{
"library_id": "",
"hidden_collections": []string{},
"collection_order": []string{},
"items_per_section": 20,
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
}
func (s *DashboardIntegrationTestSuite) TestGetPreferences_GlobalAndPerLibraryIndependent() {
token := s.setup.Token
libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Prefs Scope Test Library", false)
// Global scope: 15.
globalBody, _ := json.Marshal(map[string]interface{}{
"hidden_collections": []string{},
"collection_order": []string{},
"items_per_section": 15,
})
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(globalBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
resp.Body.Close()
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Per-library scope: 30. Must not bleed into global or vice versa.
libBody, _ := json.Marshal(map[string]interface{}{
"library_id": libraryID,
"hidden_collections": []string{},
"collection_order": []string{},
"items_per_section": 30,
})
req, _ = http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(libBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
resp.Body.Close()
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Per-library read.
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
var libPrefs map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&libPrefs)
require.NoError(s.T(), err)
assert.Equal(s.T(), float64(30), libPrefs["items_per_section"], "per-library scope should read its own row")
// Global read.
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
var globalPrefs map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&globalPrefs)
require.NoError(s.T(), err)
assert.Equal(s.T(), float64(15), globalPrefs["items_per_section"], "global scope should stay independent")
}
// TestDeleteSystemCollection_TombstonesAndRestores pins the system-
// collection permanence contract: DELETE hides the section everywhere
// (tombstone) and restore brings back the EXACT SAME row (id stable),
// never a fresh one.
func newHTTPClient() *http.Client { return &http.Client{} }
func (s *DashboardIntegrationTestSuite) TestDeleteSystemCollection_TombstonesAndRestores() {
token := s.setup.Token
libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Test Library", false)
collectionID := func(sectionID string) string {
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := newHTTPClient().Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
var response map[string]interface{}
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&response))
for _, sec := range response["sections"].([]interface{}) {
section := sec.(map[string]interface{})
if section["id"] == sectionID {
return section["collection_id"].(string)
}
}
return ""
}
hasSection := func(sectionID string) bool {
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := newHTTPClient().Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
var response map[string]interface{}
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&response))
for _, sec := range response["sections"].([]interface{}) {
if sec.(map[string]interface{})["id"] == sectionID {
return true
}
}
return false
}
idBefore := collectionID("Continue Reading")
require.NotEmpty(s.T(), idBefore, "Continue Reading should exist")
// DELETE a system collection = tombstone (204, behaves like a delete).
delReq, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/collections/"+idBefore, nil)
delReq.Header.Set("Authorization", "Bearer "+token)
delResp, err := newHTTPClient().Do(delReq)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(delResp.Body)
assert.Equal(s.T(), http.StatusNoContent, delResp.StatusCode)
// Invisible on the dashboard.
assert.False(s.T(), hasSection("Continue Reading"), "tombstoned section must vanish")
// Restore brings back the EXACT SAME row.
restoreBody, _ := json.Marshal(map[string]string{"collection_name": "Continue Reading"})
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/dashboard/restore-system-collection", bytes.NewBuffer(restoreBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
restoreResp, err := newHTTPClient().Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(restoreResp.Body)
require.Equal(s.T(), http.StatusOK, restoreResp.StatusCode)
idAfter := collectionID("Continue Reading")
assert.Equal(s.T(), idBefore, idAfter, "restore must resurrect the SAME row id")
assert.True(s.T(), hasSection("Continue Reading"))
}
// TestDeleteUserCollection_HardDelete pins the user-collection contract:
// user collections hard-delete exactly as before (no tombstone, no restore).
func (s *DashboardIntegrationTestSuite) TestDeleteUserCollection_HardDelete() {
token := s.setup.Token
createBody, _ := json.Marshal(map[string]interface{}{"name": "Test User Shelf", "type": "ebooks"})
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/collections", bytes.NewBuffer(createBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
createResp, err := newHTTPClient().Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(createResp.Body)
require.Equal(s.T(), http.StatusCreated, createResp.StatusCode)
var created map[string]interface{}
require.NoError(s.T(), json.NewDecoder(createResp.Body).Decode(&created))
collectionID := created["id"].(string)
delReq, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/collections/"+collectionID, nil)
delReq.Header.Set("Authorization", "Bearer "+token)
delResp, err := newHTTPClient().Do(delReq)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(delResp.Body)
assert.Equal(s.T(), http.StatusNoContent, delResp.StatusCode)
listReq, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/collections", nil)
listReq.Header.Set("Authorization", "Bearer "+token)
listResp, err := newHTTPClient().Do(listReq)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(listResp.Body)
var list struct {
Collections []map[string]interface{} `json:"collections"`
}
require.NoError(s.T(), json.NewDecoder(listResp.Body).Decode(&list))
for _, c := range list.Collections {
assert.NotEqual(s.T(), collectionID, c["id"], "hard-deleted user collection must be gone")
}
}
func TestDashboardIntegrationTestSuite(t *testing.T) {
suite.Run(t, new(DashboardIntegrationTestSuite))
}
+9 -4
View File
@@ -336,7 +336,7 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
}
jsonData, _ := json.Marshal(createUserPayload)
req, _ := http.NewRequest("POST", ts.URL+"/api/auth/register", bytes.NewBuffer(jsonData))
req, _ := http.NewRequest("POST", ts.URL+"/api/auth/users", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+adminToken)
@@ -416,14 +416,19 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
// Check if user creation was successful
if result["user"] == nil {
// Admin create returns the user object at the top level; the legacy
// register endpoint nested it under "user". Accept both.
userObj, ok := result["user"].(map[string]interface{})
if !ok {
userObj = result
}
if userObj["id"] == nil {
// User creation failed for another reason
t.Logf("User creation failed, response: %+v", result)
return uuid.UUID{}
}
userIDStr := result["user"].(map[string]interface{})["id"].(string)
userIDStr, _ := userObj["id"].(string)
userID, _ := uuid.Parse(userIDStr)
return userID
+2 -1
View File
@@ -512,7 +512,7 @@ func TestRegenerateDeviceToken_Forbidden(t *testing.T) {
defer setup.Server.Close()
// Create device for user 1
device1 := setup.CreateDevice(t, "User1 Device", "koreader", "user1-device")
device1 := setup.CreateDevice(t, "User1 Device", "koreader", "user1-test-device")
// Create a second user with different credentials
ctx := context.Background()
@@ -524,6 +524,7 @@ func TestRegenerateDeviceToken_Forbidden(t *testing.T) {
FirstName: pgtype.Text{String: "Different", Valid: true},
LastName: pgtype.Text{String: "User", Valid: true},
Role: "user",
Timezone: pgtype.Text{String: "UTC", Valid: true},
})
assert.NoError(t, err)
+21 -11
View File
@@ -47,7 +47,7 @@ func TestSavedFilters(t *testing.T) {
t.Run("POST /api/saved-filters creates filter", func(t *testing.T) {
reqBody := map[string]interface{}{
"name": "My Sci-Fi Books",
"name": "My Sci-Fi Test Books",
"resource_type": "media-items",
"filters": map[string]string{
"genre_filter": "Science Fiction",
@@ -71,7 +71,7 @@ func TestSavedFilters(t *testing.T) {
var filter map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&filter)
require.NoError(t, err)
assert.Equal(t, "My Sci-Fi Books", filter["name"])
assert.Equal(t, "My Sci-Fi Test Books", filter["name"])
assert.Equal(t, "media-items", filter["resource_type"])
assert.NotEmpty(t, filter["id"])
// Filters field will be JSON object (JSONB as raw bytes)
@@ -113,7 +113,7 @@ func TestSavedFilters(t *testing.T) {
t.Run("PUT /api/saved-filters/:id updates filter", func(t *testing.T) {
// Create filter first
createReq := map[string]interface{}{
"name": "Original Name",
"name": "Original Test Name",
"resource_type": "media-items",
"filters": map[string]string{"search": "original"},
}
@@ -134,11 +134,13 @@ func TestSavedFilters(t *testing.T) {
var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(string)
filterIDRaw, ok := createdFilter["id"]
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
filterID := filterIDRaw.(string)
// Update filter
updateReq := map[string]interface{}{
"name": "Updated Name",
"name": "Updated Test Name",
"resource_type": "media-items",
"filters": map[string]string{"search": "updated"},
}
@@ -159,7 +161,7 @@ func TestSavedFilters(t *testing.T) {
var updatedFilter map[string]interface{}
err = json.NewDecoder(updateResp.Body).Decode(&updatedFilter)
require.NoError(t, err)
assert.Equal(t, "Updated Name", updatedFilter["name"])
assert.Equal(t, "Updated Test Name", updatedFilter["name"])
})
t.Run("DELETE /api/saved-filters/:id deletes filter", func(t *testing.T) {
@@ -184,7 +186,9 @@ func TestSavedFilters(t *testing.T) {
var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(string)
filterIDRaw, ok := createdFilter["id"]
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
filterID := filterIDRaw.(string)
// Delete filter
deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
@@ -223,7 +227,9 @@ func TestSavedFilters(t *testing.T) {
var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(string)
filterIDRaw, ok := createdFilter["id"]
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
filterID := filterIDRaw.(string)
// Admin user tries to delete regular user's filter
deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
@@ -260,7 +266,9 @@ func TestSavedFilters(t *testing.T) {
var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(string)
filterIDRaw, ok := createdFilter["id"]
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
filterID := filterIDRaw.(string)
// Now retrieve the filter by ID
getReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
@@ -324,7 +332,7 @@ func TestSavedFilters(t *testing.T) {
t.Run("GET /api/saved-filters/:id from different user returns 404", func(t *testing.T) {
// Create filter with admin user (setup.Token)
reqBody := map[string]interface{}{
"name": "Admin Filter",
"name": "Admin Test Filter",
"resource_type": "media-items",
"filters": map[string]string{"search": "test"},
}
@@ -340,7 +348,9 @@ func TestSavedFilters(t *testing.T) {
var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(string)
filterIDRaw, ok := createdFilter["id"]
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
filterID := filterIDRaw.(string)
// Try to access with regular user (setup.RegularToken)
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
httpReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
+19 -3
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@@ -28,7 +30,21 @@ func TestBrowseLibraryFoldersEndpoint(t *testing.T) {
})
t.Run("GET /api/libraries/browse - admin can browse directories", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/browse?path=/tmp", nil)
// Self-created fixture dir: the container's /tmp may be empty, and
// the endpoint lists DIRECTORIES only — seed two so the listing is
// deterministic regardless of environment.
root := t.TempDir()
for _, name := range []string{"alpha", "beta"} {
if err := os.Mkdir(filepath.Join(root, name), 0o755); err != nil {
t.Fatal(err)
}
}
// A decoy file must NOT appear in the directory listing.
if err := os.WriteFile(filepath.Join(root, "stray.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("GET", "/api/libraries/browse?path="+root, nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
@@ -41,8 +57,8 @@ func TestBrowseLibraryFoldersEndpoint(t *testing.T) {
}
err := json.Unmarshal(rec.Body.Bytes(), &data)
assert.NoError(t, err)
assert.Equal(t, "/tmp", data.CurrentPath)
assert.NotEmpty(t, data.Directories)
assert.Equal(t, filepath.Clean(root), data.CurrentPath)
assert.ElementsMatch(t, []string{"alpha", "beta"}, data.Directories)
})
t.Run("GET /api/libraries/browse - blocks path traversal attempts", func(t *testing.T) {
+69 -6
View File
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -151,11 +152,15 @@ func TestMediaItemISBNNormalization(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Unique file_path per case: CreateMediaItem upserts ON
// CONFLICT (library_id, file_path), so a constant path would
// return the FIRST row's ISBN for every later case.
slug := strings.ReplaceAll(strings.ToLower(tc.name), " ", "-")
payload := map[string]interface{}{
"title": fmt.Sprintf("Test Book %s", tc.name),
"isbn": tc.input,
"library_id": libID,
"file_path": "/test/path.epub",
"file_path": "/test/" + slug + ".epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
@@ -203,7 +208,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
payload := map[string]interface{}{
"title": "Book without ISBN",
"library_id": libID,
"file_path": "/test/path.epub",
"file_path": "/test/book-without-isbn.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
@@ -228,7 +233,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
"title": "Multi-Hyphen ISBN",
"isbn": "978-0-306-40615-7",
"library_id": libID,
"file_path": "/test/path.epub",
"file_path": "/test/multi-hyphen-isbn.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
@@ -258,7 +263,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
"title": "Trailing Hyphen ISBN",
"isbn": "978-0-596-00965-2",
"library_id": libID,
"file_path": "/test/path.epub",
"file_path": "/test/trailing-hyphen-isbn.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
@@ -292,13 +297,15 @@ func TestMediaItemsPagination(t *testing.T) {
libID := createTestLibrary(t, setup.Server, token, "test-pagination-lib")
addFolderToLibrary(t, setup, libID, "/app/uploads")
// Create some test media items
// Create some test media items. Unique file_path per item: the create
// endpoint upserts ON CONFLICT (library_id, file_path), so a constant
// path would collapse all five creates onto one row.
for i := 1; i <= 5; i++ {
payload := map[string]interface{}{
"title": fmt.Sprintf("Book %d", i),
"isbn": fmt.Sprintf("978030640615%d", i),
"library_id": libID,
"file_path": "/test/path.epub",
"file_path": fmt.Sprintf("/test/book-%d.epub", i),
"file_size": 1024,
"mime_type": "application/epub+zip",
}
@@ -408,6 +415,62 @@ func TestMediaItemsPagination(t *testing.T) {
})
}
// TestMediaItemCreateDuplicatePath pins the create endpoint's duplicate
// behavior: a POST with a (library_id, file_path) that already exists
// answers 200 OK with the EXISTING item and duplicate=true — never a
// 201 with the old row pretending to be new, and nothing overwritten.
func TestMediaItemCreateDuplicatePath(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
libID := createTestLibrary(t, setup.Server, token, "test-dup-create-lib")
addFolderToLibrary(t, setup, libID, "/app/uploads")
createPayload := func(title string) []byte {
body, _ := json.Marshal(map[string]interface{}{
"title": title,
"author": "First Author",
"isbn": "9780306406157",
"library_id": libID,
"file_path": "/test/dup-target.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
})
return body
}
post := func(body []byte) (*http.Response, []byte) {
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp, raw
}
resp, raw := post(createPayload("Original Title"))
require.Equal(t, http.StatusCreated, resp.StatusCode, "first create should be 201")
var created map[string]interface{}
require.NoError(t, json.Unmarshal(raw, &created))
originalID := created["id"].(string)
resp, raw = post(createPayload("Different Title"))
require.Equal(t, http.StatusOK, resp.StatusCode, "duplicate create should be 200, not a 201 lie")
var dup map[string]interface{}
require.NoError(t, json.Unmarshal(raw, &dup))
assert.Equal(t, true, dup["duplicate"], "response must flag the duplicate")
assert.Equal(t, originalID, dup["item"].(map[string]interface{})["id"].(string),
"duplicate response carries the existing item")
assert.Equal(t, "Original Title", dup["item"].(map[string]interface{})["title"],
"the second create's payload must NOT overwrite the existing row")
}
// TestMediaItemLibraryRequirement tests that media items require a library
func TestMediaItemLibraryRequirement(t *testing.T) {
setup := setupTestServer(t)
@@ -0,0 +1,54 @@
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMediaNoteLengthLimits verifies the note content validator cap
// (internal/handlers/media.go): content up to 100,000 chars is accepted,
// longer content is rejected with 400.
func TestMediaNoteLengthLimits(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
mediaItemID := createTestMediaItemID(t, setup)
postNote := func(content string) int {
payload := map[string]interface{}{
"content": content,
"position": "page:1",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items/"+mediaItemID+"/notes", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
return resp.StatusCode
}
t.Run("50k-char note accepted", func(t *testing.T) {
assert.Equal(t, http.StatusCreated, postNote(strings.Repeat("a", 50000)))
})
t.Run("100k-char note accepted at boundary", func(t *testing.T) {
assert.Equal(t, http.StatusCreated, postNote(strings.Repeat("b", 100000)))
})
t.Run("over-limit note rejected", func(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, postNote(strings.Repeat("c", 100001)))
})
}
+5 -1
View File
@@ -505,7 +505,11 @@ func TestOPDSLibraryFolders(t *testing.T) {
})
t.Run("all=1 keeps the flat cross-library catalog", func(t *testing.T) {
status, contentType, body := getFeed(t, base+"/catalog?all=1")
// Explicit large page: the shared test DB accumulates items, and
// the freshly created books sort last — without a generous
// per_page they fall onto a later page and the assertion reads
// tea leaves instead of the contract.
status, contentType, body := getFeed(t, base+"/catalog?all=1&per_page=200")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, contentType, "kind=acquisition")
assert.Contains(t, body, "OPDS Folder Book 1")
@@ -0,0 +1,315 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
// OwnershipAndAdminTestSuite pins the 2026-10-13 hardening: per-user
// annotation mutations are ownership-scoped (no cross-user delete/update by
// bare row id), and admins can create users even with self-service
// registration disabled.
type OwnershipAndAdminTestSuite struct {
suite.Suite
setup *TestServerSetup
token string
itemID string
libraryID string
absFile string
}
func (s *OwnershipAndAdminTestSuite) SetupSuite() {
s.setup = setupTestServer(s.T())
s.token = s.setup.Token
var libID string
s.itemID, libID, s.absFile = s.createItemViaDB(true)
s.libraryID = libID
// createTestMediaItemID's t.Cleanup path can't be used at suite level
// (it fires after TearDownSuite has closed the server), so the item is
// created directly through the DB handle; suite cleanup relies on the
// standard "@tests.bookhoard.internal / test-named rows" passes of the
// next run's setupTestServer.
}
// createItemViaDB makes a throwaway library + media item directly through
// the test DB handle — no HTTP, no t.Cleanup entanglement.
// With withFolder=true the library gets a REAL temp folder row and the
// file is created on disk inside it, so deletes exercise real file
// removal; the folder path is returned alongside the item id.
func (s *OwnershipAndAdminTestSuite) createItemViaDB(withFolder bool) (itemID, libraryID, absFile string) {
ctx := s.setup.QueueCtx
db := s.setup.DB
libraryTypeRow, err := db.GetLibraryTypeByName(ctx, "ebooks")
require.NoError(s.T(), err, "should find ebooks library type")
admin, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
require.NoError(s.T(), err, "dev admin user should exist")
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
Name: fmt.Sprintf("Ownership Suite Test Library %d", time.Now().UnixNano()),
Description: pgtype.Text{String: "annotation ownership suite", Valid: true},
LibraryTypeID: libraryTypeRow.ID,
CreatedByAdminID: admin.ID,
})
require.NoError(s.T(), err)
relPath := fmt.Sprintf("ownership-suite-%d.epub", time.Now().UnixNano())
absFile = ""
if withFolder {
dir, dirErr := os.MkdirTemp("", "ownership-suite-*")
require.NoError(s.T(), dirErr)
_, ferr := db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: library.ID,
FolderPath: dir,
})
require.NoError(s.T(), ferr)
absFile = filepath.Join(dir, relPath)
if werr := os.WriteFile(absFile, []byte("restored book"), 0o644); werr != nil {
require.NoError(s.T(), werr)
}
}
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Ownership Suite Item",
Author: pgtype.Text{String: "Suite Author", Valid: true},
FilePath: relPath,
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(s.T(), err)
return uuid.UUID(item.ID.Bytes).String(), uuid.UUID(library.ID.Bytes).String(), absFile
}
func (s *OwnershipAndAdminTestSuite) TearDownSuite() {
s.setup.Close()
}
func (s *OwnershipAndAdminTestSuite) getItem(itemID string) map[string]interface{} {
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/media-items/"+itemID, nil)
req.Header.Set("Authorization", "Bearer "+s.token)
resp, err := (&http.Client{}).Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
var body map[string]interface{}
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body))
return body
}
func (s *OwnershipAndAdminTestSuite) createHighlight(token string) map[string]interface{} {
payload, _ := json.Marshal(map[string]interface{}{
"selection_text": "owned annotation text",
"start_position": "p1",
"end_position": "p1",
"color": "#ffff00",
})
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
require.Equal(s.T(), http.StatusCreated, resp.StatusCode, "highlight create should succeed")
var body map[string]interface{}
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body))
return body
}
func (s *OwnershipAndAdminTestSuite) highlightExists(token, highlightID string) bool {
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
var body []map[string]interface{}
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body))
for _, h := range body {
if h["id"] == highlightID {
return true
}
}
return false
}
// TestHighlightDelete_CrossUserNoOp pins the ownership predicate: user B's
// DELETE of user A's highlight by bare id must not remove A's row.
func (s *OwnershipAndAdminTestSuite) TestHighlightDelete_CrossUserNoOp() {
regular := s.setup.RegularToken
created := s.createHighlight(regular)
highlightID, ok := created["id"].(string)
require.True(s.T(), ok, "create response carries the highlight id")
// The ADMIN (a different user) tries to delete it by id.
req, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights/"+highlightID, nil)
req.Header.Set("Authorization", "Bearer "+s.token)
resp, err := (&http.Client{}).Do(req)
require.NoError(s.T(), err)
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
assert.True(s.T(), s.highlightExists(regular, highlightID),
"another user's delete-by-id must not remove the owner's highlight")
// The owner deletes their own highlight — gone.
req, _ = http.NewRequest("DELETE", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights/"+highlightID, nil)
req.Header.Set("Authorization", "Bearer "+regular)
resp, err = (&http.Client{}).Do(req)
require.NoError(s.T(), err)
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
assert.False(s.T(), s.highlightExists(regular, highlightID), "owner delete removes the row")
}
// TestAdminCreateUser pins the admin counterpart to registration: admins
// create accounts even with self-service registration disabled, regular
// users cannot, and duplicates conflict.
func (s *OwnershipAndAdminTestSuite) TestAdminCreateUser() {
suffix := fmt.Sprintf("%d", s.setup.DBPool.Config().MaxConns) // stable-ish uniquifier
unique := fmt.Sprintf("admincreated%s", suffix)
// Regular user token → 403 (AdminMiddleware).
payload, _ := json.Marshal(map[string]interface{}{
"email": unique + "@tests.bookhoard.internal",
"username": unique,
"password": "Test@Pass123!",
})
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/auth/users", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.setup.RegularToken)
resp, err := (&http.Client{}).Do(req)
require.NoError(s.T(), err)
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
require.Equal(s.T(), http.StatusForbidden, resp.StatusCode)
// Admin → 201, and the account is immediately usable (login works)
// even though self-service registration is off for new servers.
req, _ = http.NewRequest("POST", s.setup.Server.URL+"/api/auth/users", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.token)
resp, err = (&http.Client{}).Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
require.Equal(s.T(), http.StatusCreated, resp.StatusCode)
var created map[string]interface{}
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&created))
assert.Equal(s.T(), unique, created["username"])
assert.Equal(s.T(), "user", created["role"])
// New users inherit the system-wide default timezone (this was the
// point of the setting; before 2026-10-13 every new user got UTC
// regardless of it).
expectedTZ := "UTC"
var settingTZ string
err = s.setup.DBPool.QueryRow(s.setup.QueueCtx,
"SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone'").Scan(&settingTZ)
if err == nil {
if _, locErr := time.LoadLocation(settingTZ); locErr == nil && settingTZ != "" {
expectedTZ = settingTZ
}
}
assert.Equal(s.T(), expectedTZ, created["timezone"],
"created user must carry the system default timezone")
// Duplicate username → 409.
req, _ = http.NewRequest("POST", s.setup.Server.URL+"/api/auth/users", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.token)
resp, err = (&http.Client{}).Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusConflict, resp.StatusCode)
// The created user can log in.
loginPayload, _ := json.Marshal(map[string]interface{}{
"login": unique + "@tests.bookhoard.internal",
"password": "Test@Pass123!",
})
req, _ = http.NewRequest("POST", s.setup.Server.URL+"/api/auth/login", bytes.NewBuffer(loginPayload))
req.Header.Set("Content-Type", "application/json")
resp, err = (&http.Client{}).Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
}
// TestDelete_StorageDownRefused pins the NAS guard: deleting a media item
// while its library storage is unreachable is REFUSED (row + annotations
// survive) instead of cascading annotations for a file that will re-import
// bare on the next scan.
func (s *OwnershipAndAdminTestSuite) TestDelete_StorageDownRefused() {
ctx := s.setup.QueueCtx
db := s.setup.DB
libraryTypeRow, err := db.GetLibraryTypeByName(ctx, "ebooks")
require.NoError(s.T(), err)
admin, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
require.NoError(s.T(), err)
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
Name: fmt.Sprintf("Ownership Suite Test Offline %d", time.Now().UnixNano()),
Description: pgtype.Text{String: "storage-down suite", Valid: true},
LibraryTypeID: libraryTypeRow.ID,
CreatedByAdminID: admin.ID,
})
require.NoError(s.T(), err)
// Folder row pointing at a mount that does not exist.
_, err = db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: library.ID,
FolderPath: "/nonexistent-mount-xyz",
})
require.NoError(s.T(), err)
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Offline Storage Item",
FilePath: "offline.epub",
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(s.T(), err)
itemID := uuid.UUID(item.ID.Bytes).String()
payload, _ := json.Marshal(map[string]interface{}{"media_item_ids": []string{itemID}})
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/media-items/bulk-delete", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.token)
resp, err := (&http.Client{}).Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
require.Equal(s.T(), http.StatusOK, resp.StatusCode, "bulk endpoint answers 200 with per-item errors")
var body map[string]interface{}
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body))
results := body["results"].([]interface{})
require.Len(s.T(), results, 1)
first := results[0].(map[string]interface{})
assert.Contains(s.T(), first["error"], "storage unreachable")
// The row survived the refused delete.
check, err := db.GetMediaItem(ctx, item.ID)
require.NoError(s.T(), err)
assert.Equal(s.T(), "Offline Storage Item", check.Title)
}
func TestOwnershipAndAdminTestSuite(t *testing.T) {
suite.Run(t, new(OwnershipAndAdminTestSuite))
}
+9 -18
View File
@@ -16,8 +16,7 @@ func TestProcessingIssuesListAuthentication(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Auth Library", "manga")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Auth Library", "manga")
t.Run("GET /api/libraries/:id/issues/list - No authentication", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
@@ -55,8 +54,7 @@ func TestProcessingIssuesListResponseStructure(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Structure Library", "comics")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Structure Library", "comics")
t.Run("Returns JSON array for empty library", func(t *testing.T) {
token := setup.Token
@@ -163,8 +161,7 @@ func TestProcessingIssueStatsAuthentication(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Stats Auth Library", "manga")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Stats Auth Library", "manga")
t.Run("GET /api/libraries/:id/issues/stats - No authentication", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
@@ -202,8 +199,7 @@ func TestProcessingIssueStatsResponseStructure(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Stats Structure Library", "comics")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Stats Structure Library", "comics")
t.Run("Returns correct structure for empty library", func(t *testing.T) {
token := setup.Token
@@ -343,11 +339,9 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
setup := setupTestServer(t)
// Create two different libraries with different types
deviceSetup1 := setupDeviceTest(t)
library1ID := deviceSetup1.CreateLibrary(t, "Isolation Test Library 1", "manga")
library1ID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Isolation Test Library 1", "manga")
deviceSetup2 := setupDeviceTest(t)
library2ID := deviceSetup2.CreateLibrary(t, "Isolation Test Library 2", "comics")
library2ID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Isolation Test Library 2", "comics")
token := setup.Token
@@ -431,8 +425,7 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
for _, lt := range libraryTypes {
t.Run(lt.name+" - list endpoint works", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test "+lt.name, lt.libraryType)
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test "+lt.name, lt.libraryType)
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer "+token)
@@ -448,8 +441,7 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
})
t.Run(lt.name+" - stats endpoint works", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test "+lt.name, lt.libraryType)
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test "+lt.name, lt.libraryType)
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
req.Header.Set("Authorization", "Bearer "+token)
@@ -474,8 +466,7 @@ func TestProcessingIssuesInvalidTokens(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Token Library", "ebooks")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Token Library", "ebooks")
t.Run("List endpoint rejects invalid token", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
+179
View File
@@ -656,3 +656,182 @@ func TestProgressWeb_EdgeCases(t *testing.T) {
assert.InDelta(t, 1.5, progress.ZoomLevel.Float64, 0.01)
})
}
// TestDeletedUserTokenRejected pins the ghost-session guard: a
// signature-valid JWT for a user that has been DELETED must stop
// authenticating (401 user_not_found), not serve a phantom session.
// Before the guard, deleted users kept full API access until token
// expiry (observed live on hardware 2026-10-09).
func TestDeletedUserTokenRejected(t *testing.T) {
setup := setupTestServer(t)
ctx := context.Background()
// Create a dedicated user, then log in as it to get a fresh token.
// Admin-created (not self-registered) so the test stays deterministic
// regardless of the registration_enabled setting.
createResp := doReq(t, "POST", setup.Server.URL+"/api/auth/users", map[string]interface{}{
"email": "ghost-test@tests.bookhoard.internal",
"username": "ghosttest",
"password": "Ghost@Pass123!",
}, setup.Token)
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Skipf("admin user creation unavailable (status %d)", createResp.StatusCode)
}
loginResp := doReq(t, "POST", setup.Server.URL+"/api/auth/login", map[string]interface{}{
"login": "ghost-test@tests.bookhoard.internal",
"password": "Ghost@Pass123!",
}, "")
defer loginResp.Body.Close()
require.Equal(t, http.StatusOK, loginResp.StatusCode)
var loginBody struct {
AccessToken string `json:"access_token"`
}
require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&loginBody))
require.NotEmpty(t, loginBody.AccessToken)
ghostToken := loginBody.AccessToken
// Sanity: the token works while the user exists (user-scoped endpoint
// a freshly-registered regular user can access).
okResp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, ghostToken)
defer okResp.Body.Close()
require.Equal(t, http.StatusOK, okResp.StatusCode, "token must work while the user exists")
// Delete the user directly via the DB for determinism (same query the
// suite's own cleanup uses).
ghostID := ""
users, err := setup.DB.ListUsers(ctx)
require.NoError(t, err)
for _, u := range users {
if u.Email == "ghost-test@tests.bookhoard.internal" {
ghostID = uuid.UUID(u.ID.Bytes).String()
require.NoError(t, setup.DB.DeleteUser(ctx, u.ID))
}
}
require.NotEmpty(t, ghostID, "ghost user row must exist before deletion")
// The same token must now be rejected with 401 user_not_found.
resp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, ghostToken)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "ghost token must be rejected")
var errBody map[string]string
require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody))
assert.Contains(t, errBody["message"], "user_not_found")
}
// TestProgressWeb_LocatorCoherence pins the locator coherence rule: a
// percentage-only write that DECLARES no locator (ClearLocator) must clear
// any inherited stale anchor instead of re-pairing the new percentage with
// the previous writer's CFI/context — the incoherent row shape that fed the
// client-side far-anchor restore floor. Request-carried fields always win.
func TestProgressWeb_LocatorCoherence(t *testing.T) {
setup := setupTestServer(t)
ctx := context.Background()
userID := getTestUserID(t, setup.DB)
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
seedAnchor := func(t *testing.T, mediaItemID string) uuid.UUID {
mediaUUID, _ := uuid.Parse(mediaItemID)
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
Source: "web",
Percentage: pFloat64(0.3),
Epubcfi: pStr("epubcfi(/6/4/2:first)"),
ContextText: pStr("some anchor context sentence"),
DeviceType: "web",
Broadcast: false,
})
require.NoError(t, err)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
require.True(t, progress.Epubcfi.Valid, "seed: anchor stored")
return mediaUUID
}
t.Run("percentage-only kobo write clears the inherited anchor", func(t *testing.T) {
mediaItemID := createTestMediaItemID(t, setup)
mediaUUID := seedAnchor(t, mediaItemID)
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
Source: "kobo",
Percentage: pFloat64(0.6),
DeviceType: "kobo",
ClearLocator: true,
Broadcast: false,
})
require.NoError(t, err)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001, "percentage updated")
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared, not re-paired")
assert.False(t, progress.ContextText.Valid, "stale context cleared")
assert.Equal(t, "kobo", progress.LastSyncSource.String)
})
t.Run("request-carried context survives the clear", func(t *testing.T) {
mediaItemID := createTestMediaItemID(t, setup)
mediaUUID := seedAnchor(t, mediaItemID)
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
Source: "koreader",
Percentage: pFloat64(0.7),
ContextText: pStr("a brand new context sentence"),
DeviceType: "koreader",
ClearLocator: true,
Broadcast: false,
})
require.NoError(t, err)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
assert.InDelta(t, 0.7, progress.Percentage.Float64, 0.001)
assert.True(t, progress.ContextText.Valid, "request-carried context kept")
assert.Equal(t, "a brand new context sentence", progress.ContextText.String)
// The verify gate is inert in the test harness (no media path
// resolver wired), so no anchor can be minted here; the rule
// under test is that the STALE one is gone.
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared")
})
t.Run("HTTP merge semantics untouched: key-absent PUT preserves fields", func(t *testing.T) {
mediaItemID := createTestMediaItemID(t, setup)
mediaUUID := seedAnchor(t, mediaItemID)
// Service-level save WITHOUT ClearLocator mimics the HTTP merge
// path (field absent = inherit): the anchor must survive.
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
Source: "queue",
Percentage: pFloat64(0.4),
Broadcast: false,
})
require.NoError(t, err)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
assert.True(t, progress.Epubcfi.Valid, "merge path preserves the anchor")
assert.Equal(t, "epubcfi(/6/4/2:first)", progress.Epubcfi.String)
})
}
+5 -5
View File
@@ -384,12 +384,12 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
book2ID := createTestMediaItemIDInLibrary(t, client, setup, lib2Resp["id"].(string), "Harry Potter 2")
tests := []struct {
name string
query string
libraryID string
expectedCount int
name string
query string
libraryID string
expectedCount int
expectedStatus int
shouldContain string // Comma-separated list of book IDs to check
shouldContain string // Comma-separated list of book IDs to check
}{
{
name: "no filter - both test books present",
+7 -2
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -34,15 +35,19 @@ func TestUnifiedSearch(t *testing.T) {
_ = folderResp.Body.Close()
require.Equal(t, http.StatusCreated, folderResp.StatusCode)
// Helper to create book with fields
// Helper to create book with fields. Unique file_path per book: the
// create endpoint answers 200/duplicate for a repeated
// (library_id, file_path), and the old shared constant made the 2nd/3rd
// creates no-ops anyway.
createBook := func(title, author, genre string, year int, hasCover bool) {
slug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
bookReq := map[string]interface{}{
"library_id": libraryID,
"title": title,
"author": author,
"genre": genre,
"copyright_year": year,
"file_path": "/tmp/test.epub",
"file_path": "/tmp/" + slug + ".epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
+112 -9
View File
@@ -32,6 +32,11 @@ func (s *SeriesIntegrationTestSuite) TearDownSuite() {
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
// Ground truth (2026-10-13 visibility hardening): library_id stays
// optional, but the unscoped aggregate now covers only the requester's
// VISIBLE libraries — hidden libraries contribute nothing. Series are
// grouped per library (same name in two libraries = two entries), so
// every row carries a library_id.
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
req.Header.Set("Authorization", "Bearer "+s.token)
@@ -42,12 +47,20 @@ func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var body map[string]string
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
assert.Equal(s.T(), "library_id required", body["error"])
assert.Contains(s.T(), body, "series", "response carries the series array")
assert.Contains(s.T(), body, "total")
if rows, ok := body["series"].([]interface{}); ok {
for _, r := range rows {
row, ok := r.(map[string]interface{})
require.True(s.T(), ok)
assert.NotEmpty(s.T(), row["library_id"], "series rows carry their library scope")
}
}
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_InvalidLibraryID() {
@@ -159,7 +172,13 @@ func (s *SeriesIntegrationTestSuite) TestGetSeries_LimitClampedTo100() {
assert.Equal(s.T(), float64(100), limit, "limit should be clamped to 100")
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresLibraryID() {
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_LibraryIDRequired() {
// Ground truth (2026-10-13 visibility hardening): /api/series/books is
// per-library now — library_id is REQUIRED, validated, and must be
// visible to the requester. The former name-only contract let any
// authenticated account pull series books across ALL libraries
// (including hidden ones), and merged same-name series across
// libraries (manga adaptations / LN splits).
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?name=Test+Series", nil)
req.Header.Set("Authorization", "Bearer "+s.token)
@@ -199,6 +218,7 @@ func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresName() {
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_InvalidLibraryID() {
// library_id is now validated: an unparseable uuid is a 400.
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id=bad-uuid&name=Test", nil)
req.Header.Set("Authorization", "Bearer "+s.token)
@@ -296,6 +316,83 @@ func (s *SeriesIntegrationTestSuite) TestGetSeries_ResponseStructure() {
assert.True(s.T(), ok, "'series' should be an array")
}
func (s *SeriesIntegrationTestSuite) setLibraryVisibility(token, libraryID string, visible bool) *http.Response {
payload, _ := json.Marshal(map[string]interface{}{
"library_id": libraryID,
"is_visible": visible,
})
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/libraries/visibility", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
require.NoError(s.T(), err)
return resp
}
// TestGetSeries_HiddenLibraryScopedOut pins the family-lens contract: a
// library hidden from a user contributes NOTHING to that user's series
// views, and naming it explicitly is a 403, not a silent pass — across
// /api/series, /api/series/books, and /api/media-items.
func (s *SeriesIntegrationTestSuite) TestGetSeries_HiddenLibraryScopedOut() {
regular := s.setup.RegularToken
// Baseline: the regular user can see the suite library.
resp := s.setLibraryVisibility(regular, s.libraryID, true)
defer resp.Body.Close()
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Hide it from the regular user.
resp = s.setLibraryVisibility(regular, s.libraryID, false)
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
defer func() {
r := s.setLibraryVisibility(regular, s.libraryID, true)
_ = r.Body.Close()
}()
client := &http.Client{}
// Explicit hidden library → 403.
for _, path := range []string{
"/api/series?library_id=" + s.libraryID,
"/api/series/books?library_id=" + s.libraryID + "&name=Test",
"/api/media-items?library_id=" + s.libraryID,
} {
req, _ := http.NewRequest("GET", s.setup.Server.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+regular)
r, err := client.Do(req)
require.NoError(s.T(), err)
body, _ := io.ReadAll(r.Body)
r.Body.Close()
require.Equalf(s.T(), http.StatusForbidden, r.StatusCode, "%s must be 403 for a hidden library (body: %s)", path, body)
}
// Unscoped aggregate: the hidden library's series never appear.
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
req.Header.Set("Authorization", "Bearer "+regular)
r, err := client.Do(req)
require.NoError(s.T(), err)
defer r.Body.Close()
require.Equal(s.T(), http.StatusOK, r.StatusCode)
var body map[string]interface{}
require.NoError(s.T(), json.NewDecoder(r.Body).Decode(&body))
if rows, ok := body["series"].([]interface{}); ok {
for _, row := range rows {
m, ok := row.(map[string]interface{})
require.True(s.T(), ok)
assert.NotEqual(s.T(), s.libraryID, m["library_id"], "hidden library must not appear in the aggregate")
}
}
// The admin still sees the library (default-allow; visibility rows are
// per-user).
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id="+s.libraryID, nil)
req.Header.Set("Authorization", "Bearer "+s.token)
r, err = client.Do(req)
require.NoError(s.T(), err)
defer r.Body.Close()
assert.Equal(s.T(), http.StatusOK, r.StatusCode)
}
func (s *SeriesIntegrationTestSuite) TestRestoreSystemCollection_ContinueSeries() {
reqBody := map[string]interface{}{
"collection_name": "Continue Series",
@@ -349,11 +446,17 @@ func (s *SeriesIntegrationTestSuite) TestGetSections_IncludesContinueSeries() {
sectionIDs[section["id"].(string)] = true
}
assert.Contains(s.T(), sectionIDs, "continue-series", "dashboard should include continue-series section")
assert.Contains(s.T(), sectionIDs, "continue-reading")
assert.Contains(s.T(), sectionIDs, "recently-added")
assert.Contains(s.T(), sectionIDs, "recently-read")
assert.Contains(s.T(), sectionIDs, "not-started")
// Ground truth: the /sections contract keys a section's identity on
// the collection NAME — BuildSections sets ID = CollectionName, and
// the web/app reorder + hidden-collection preferences persist those
// same strings (data-collection-id). The kebab "query_type"
// ("continue-series" etc.) is the STORAGE key for system collections
// and is not exposed on this endpoint.
assert.Contains(s.T(), sectionIDs, "Continue Series", "dashboard should include Continue Series")
assert.Contains(s.T(), sectionIDs, "Continue Reading")
assert.Contains(s.T(), sectionIDs, "Recently Added")
assert.Contains(s.T(), sectionIDs, "Recently Read")
assert.Contains(s.T(), sectionIDs, "Not Started")
}
func TestSeriesIntegrationTestSuite(t *testing.T) {
@@ -55,6 +55,7 @@ func createSyncTestUser(t *testing.T, db *database.Queries) pgtype.UUID {
FirstName: pgtype.Text{String: "Test", Valid: true},
LastName: pgtype.Text{String: "Sync", Valid: true},
Role: "user",
Timezone: pgtype.Text{String: "UTC", Valid: true},
})
require.NoError(t, err)
+7 -2
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -34,14 +35,18 @@ func TestTagsFilter(t *testing.T) {
_ = folderResp.Body.Close()
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Should add folder to library")
// Helper to create book with tags
// Helper to create book with tags. The file_path must be UNIQUE per
// book: CreateMediaItem upserts ON CONFLICT (library_id, file_path),
// so a constant path collapses every create onto the first row and
// the later books' tags never land.
createBook := func(title, author string, tags []string) {
slug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
bookReq := map[string]interface{}{
"library_id": libraryID,
"title": title,
"author": author,
"tags": tags,
"file_path": "/tmp/test.epub",
"file_path": "/tmp/" + slug + ".epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
+12
View File
@@ -109,6 +109,18 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
require.NoError(t, err)
libraryID := libResponse["id"].(string)
// Every library a test creates must not outlive that test — delete
// it on cleanup so runs leave no residue in the dev database.
t.Cleanup(func() {
delHTTP, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/api/libraries/%s", ts.URL, libraryID), nil)
delHTTP.Header.Set("Authorization", "Bearer "+token)
delResp, err := client.Do(delHTTP)
if err == nil {
_, _ = io.Copy(io.Discard, delResp.Body)
_ = delResp.Body.Close()
}
})
if withFolder {
folderReq := map[string]interface{}{
"folder_path": "/app/uploads",
+143 -50
View File
@@ -77,21 +77,21 @@ type DeviceTestData struct {
// TestServerSetup manages the lifecycle of a test server with proper resource cleanup
type TestServerSetup struct {
Server *httptest.Server
DB *database.Queries
DBPool *pgxpool.Pool
Config *config.Config
ConnManager *wsync.ConnectionManager
QueueProcessor *wsync.SyncQueueProcessor
ProgressService *wsync.ProgressService
Server *httptest.Server
DB *database.Queries
DBPool *pgxpool.Pool
Config *config.Config
ConnManager *wsync.ConnectionManager
QueueProcessor *wsync.SyncQueueProcessor
ProgressService *wsync.ProgressService
AnnotationService *wsync.AnnotationService
CleanupCancel context.CancelFunc
QueueCtx context.Context
QueueCancel context.CancelFunc
Token string
RegularToken string
mu sync.Mutex
closed bool
CleanupCancel context.CancelFunc
QueueCtx context.Context
QueueCancel context.CancelFunc
Token string
RegularToken string
mu sync.Mutex
closed bool
}
// Close cleans up all resources in the correct order
@@ -246,6 +246,7 @@ func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData {
FirstName: pgtype.Text{String: "Regular", Valid: true},
LastName: pgtype.Text{String: "User", Valid: true},
Role: "user",
Timezone: pgtype.Text{String: "UTC", Valid: true},
})
require.NoError(t, err, "Should create regular test user")
@@ -275,6 +276,10 @@ func uuidToPGType(u uuid.UUID) pgtype.UUID {
// createDefaultCollectionsForUser creates the 4 default system collections for a user
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
ctx := context.Background()
// Canonical Title-case names — the same list the registration seed
// (CreateDefaultCollectionsForUser) and RestoreSystemCollection use.
// A stale kebab-case copy lived here once and duplicated every
// system collection in the shared dev DB.
defaultCollections := []struct {
Name string
Description string
@@ -283,14 +288,34 @@ func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID
QueryType string
Priority int32
}{
{"continue-reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
{"recently-added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
{"recently-read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
{"not-started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
{"continue-series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
{"Continue Reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
{"Recently Added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
{"Recently Read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
{"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
{"Continue Series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
}
for _, col := range defaultCollections {
// Idempotent by the STABLE identity (user + query_type): existing
// rows (renamed, tombstoned) heal to canonical metadata in place;
// insert only when the query type has no row. A blind insert here
// hit UNIQUE(user_id, name) on every preserved-admin rerun.
existing, gerr := db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
UserID: userID,
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
})
if gerr == nil {
_, uerr := db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
ID: existing.ID,
Name: col.Name,
Description: pgtype.Text{String: col.Description, Valid: true},
Icon: pgtype.Text{String: col.Icon, Valid: true},
Color: pgtype.Text{String: col.Color, Valid: true},
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
})
require.NoError(t, uerr, "Should restore default collection: "+col.Name)
continue
}
_, err := db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
UserID: userID,
Name: col.Name,
@@ -515,39 +540,43 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// Setup routes using router package
routerConfig := &router.Config{
Echo: e,
Queries: queries,
Cfg: cfg,
DBPool: dbPool,
AuthHandler: authHandler,
LibraryHandler: libraryHandler,
DeviceHandler: deviceHandler,
MediaHandler: mediaHandler,
MatchingHandler: matchingHandler,
KOReaderHandler: koreaderHandler,
WSHandler: wsHandler,
ConflictHandler: conflictHandler,
AnalyticsHandler: analyticsHandler,
QueueHandler: queueHandler,
SystemSettingsHandler: systemSettingsHandler,
Echo: e,
Queries: queries,
Cfg: cfg,
DBPool: dbPool,
AuthHandler: authHandler,
LibraryHandler: libraryHandler,
DeviceHandler: deviceHandler,
MediaHandler: mediaHandler,
MatchingHandler: matchingHandler,
KOReaderHandler: koreaderHandler,
WSHandler: wsHandler,
ConflictHandler: conflictHandler,
AnalyticsHandler: analyticsHandler,
QueueHandler: queueHandler,
SystemSettingsHandler: systemSettingsHandler,
ProcessingIssuesHandler: processingIssuesHandler,
CollectionHandler: collectionHandler,
FiltersHandler: filtersHandler,
DashboardHandler: dashboardHandler,
DashboardService: dashboardService,
SeriesHandler: seriesHandler,
OPDSHandler: opdsHandler,
JobsHandler: jobsHandler,
ConnManager: connManager,
QueueProcessor: queueProcessor,
ProgressService: progressService,
AnnotationService: annotationService,
DeviceAuthMiddleware: deviceAuthMiddleware,
LoginTracker: loginAttemptTracker,
CollectionHandler: collectionHandler,
FiltersHandler: filtersHandler,
DashboardHandler: dashboardHandler,
DashboardService: dashboardService,
SeriesHandler: seriesHandler,
OPDSHandler: opdsHandler,
JobsHandler: jobsHandler,
ConnManager: connManager,
QueueProcessor: queueProcessor,
ProgressService: progressService,
AnnotationService: annotationService,
DeviceAuthMiddleware: deviceAuthMiddleware,
LoginTracker: loginAttemptTracker,
// RegisterRoutes dereferences Settings for the auth rate limit;
// the main binary wires database.NewSettingsRegistry(queries) —
// the test harness must too or route registration nil-panics.
Settings: database.NewSettingsRegistry(queries),
// main.go:246 passes the shared LibraryService here; omitting it
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
LibraryService: libraryService,
}
router.RegisterRoutes(routerConfig)
@@ -623,9 +652,41 @@ func setupTestServer(t *testing.T) *TestServerSetup {
require.NoError(t, cerr, "Failed to create admin test user")
adminUUID, err = uuid.FromBytes(adminUser.ID.Bytes[:])
require.NoError(t, err, "Failed to parse admin user UUID")
// Default collections are created exactly once per user; the
// preserved dev admin already has its set from the first run.
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
}
// The canonical system-collection set is ensured on EVERY run: the
// helper is idempotent by (user, query_type) and heals tombstoned or
// renamed rows, so suite runs can't accumulate naming-drift copies.
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
// Delete test-created COLLECTIONS (names containing "test"): without
// this, every suite run leaves its collections behind and the next
// run's creates die on UNIQUE(user_id, name) — the "collections
// 500-panic" rot. System collections are skipped (permanent rows).
// Same convention as the library pass: names containing "test".
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
if cerr == nil {
for _, coll := range adminColls {
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
queries.DeleteCollection(ctx, coll.ID)
}
}
}
// Same passes for test DEVICES (identifier/name containing "test")
// and test SAVED FILTERS (name containing "test"): both accumulated
// across runs and poisoned later runs (duplicate identifiers, 409s,
// non-empty "initially empty" assertions).
_, derr := dbPool.Exec(ctx,
"DELETE FROM devices WHERE user_id = $1 AND (device_identifier ILIKE '%test%' OR device_name ILIKE '%test%')",
adminUUID)
if derr != nil {
t.Logf("device cleanup failed: %v", derr)
}
_, ferr := dbPool.Exec(ctx,
"DELETE FROM saved_filters WHERE user_id = $1 AND name ILIKE '%test%'",
adminUUID)
if ferr != nil {
t.Logf("saved-filter cleanup failed: %v", ferr)
}
// Create fresh regular test user
@@ -636,6 +697,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
FirstName: pgtype.Text{String: "Regular", Valid: true},
LastName: pgtype.Text{String: "User", Valid: true},
Role: "user",
Timezone: pgtype.Text{String: "UTC", Valid: true},
})
require.NoError(t, err, "Failed to create regular test user")
@@ -676,6 +738,17 @@ func setupTestServer(t *testing.T) *TestServerSetup {
}
}
// Same pass for test-created COLLECTIONS (the "collections
// 500-panic" rot: they lingered forever and poisoned later runs).
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
if cerr == nil {
for _, coll := range adminColls {
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
queries.DeleteCollection(ctx, coll.ID)
}
}
}
if err := setup.Close(); err != nil {
t.Errorf("Failed to cleanup test server: %v", err)
}
@@ -723,6 +796,26 @@ func getTestUserID(t *testing.T, db *database.Queries) uuid.UUID {
return userUUID
}
// createLibraryDirect inserts a library owned by the given user WITHOUT a
// nested test-server setup: a second setupTestServer bootstraps away the
// first setup's users, and the ghost-session guard rightly rejects the
// outer test's tokens from then on.
func createLibraryDirect(t *testing.T, db *database.Queries, ownerID uuid.UUID, name, libraryType string) string {
ctx := context.Background()
libraryTypeRow, err := db.GetLibraryTypeByName(ctx, libraryType)
require.NoError(t, err, "Should find library type")
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
Name: name,
Description: pgtype.Text{String: "Test library description", Valid: true},
LibraryTypeID: libraryTypeRow.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(ownerID), Valid: true},
})
require.NoError(t, err, "Should create library")
libraryUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
require.NoError(t, err, "Should parse library ID")
return libraryUUID.String()
}
func getRegularUserID(t *testing.T, db *database.Queries) uuid.UUID {
ctx := context.Background()
user, err := db.GetUserByEmail(ctx, "testregularuser@tests.bookhoard.internal")
+55 -6
View File
@@ -8,22 +8,65 @@ import (
"net/http/httptest"
"testing"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
)
// ensureSingleAdmin makes the "last admin" precondition deterministic in
// the shared test DB: only the preserved testuser admin remains. Other
// suites may have promoted or renamed users; the last-admin semantics
// under test require a true singleton.
func ensureSingleAdmin(t *testing.T, setup *TestServerSetup) {
ctx := context.Background()
_, err := setup.DBPool.Exec(ctx,
"DELETE FROM users WHERE role = 'admin' AND email != 'testuser@tests.bookhoard.internal'")
require.NoError(t, err, "Failed to prune extra admins")
if _, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal"); err != nil {
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
_, cerr := setup.DB.CreateUser(ctx, database.CreateUserParams{
Email: "testuser@tests.bookhoard.internal",
Username: "testuser",
PasswordHash: passwordHash,
Role: "admin",
Timezone: pgtype.Text{String: "UTC", Valid: true},
})
require.NoError(t, cerr, "Failed to restore the shared admin")
}
users, _ := setup.DB.ListUsers(ctx)
adminCount := 0
for _, u := range users {
if u.Role == "admin" {
adminCount++
}
}
require.Equal(t, 1, adminCount, "precondition: exactly one admin")
}
func TestUpdateProfileSelfEdit(t *testing.T) {
t.Run("PUT /api/auth/profile - Update all fields successfully", func(t *testing.T) {
setup := setupTestServer(t)
_ = createTestUserOnce(t, setup.DB)
token := setup.Token
// Self-edit a DEDICATED user, never the shared admin: renaming
// testuser here leaked a second admin row into the shared DB (the
// renamed email escapes the harness's preserve-list cleanup) and
// broke the last-admin tests downstream.
selfUser := createRegularUserOnce(t, setup.DB)
token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password)
// Unique rename target: a fixed name collides with stale rows from
// earlier runs of this very test (409 username conflict).
suffix := uuid.New().String()[:8]
newUsername := "updateduser-" + suffix
updateData := map[string]interface{}{
"username": "updateduser",
"email": "updated@tests.bookhoard.internal",
"username": newUsername,
"email": "updated-" + suffix + "@tests.bookhoard.internal",
"first_name": "Updated",
"last_name": "Name",
"theme": "dracula",
@@ -39,9 +82,9 @@ func TestUpdateProfileSelfEdit(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
user, err := setup.DB.GetUserByUsername(context.Background(), "updateduser")
user, err := setup.DB.GetUserByUsername(context.Background(), newUsername)
require.NoError(t, err)
assert.Equal(t, "updated@tests.bookhoard.internal", user.Email)
assert.Equal(t, "updated-"+suffix+"@tests.bookhoard.internal", user.Email)
assert.Equal(t, "Updated", user.FirstName.String)
assert.Equal(t, "Name", user.LastName.String)
assert.Equal(t, "dracula", user.Theme.String)
@@ -215,6 +258,10 @@ func TestUpdateProfileAdminMode(t *testing.T) {
t.Run("PUT /api/auth/profile/:id - Try to demote last admin", func(t *testing.T) {
setup := setupTestServer(t)
// Deterministic singleton-admin precondition (shared DB may carry
// promoted/renamed admin rows from other suites).
ensureSingleAdmin(t, setup)
// Delete the regular user so we have only 1 admin (testuser)
regularUser := createRegularUserOnce(t, setup.DB)
err := setup.DB.DeleteUser(context.Background(), uuidToPGType(regularUser.ID))
@@ -435,6 +482,7 @@ func TestDeleteUserConsolidated(t *testing.T) {
t.Run("DELETE /api/auth/profile - Last admin tries self-delete", func(t *testing.T) {
setup := setupTestServer(t)
ensureSingleAdmin(t, setup)
_ = createTestUserOnce(t, setup.DB)
token := setup.Token
@@ -471,6 +519,7 @@ func TestDeleteUserConsolidated(t *testing.T) {
t.Run("DELETE /api/auth/profile/:id - Admin tries delete last admin", func(t *testing.T) {
setup := setupTestServer(t)
ensureSingleAdmin(t, setup)
_ = createTestUserOnce(t, setup.DB)
token := setup.Token
+4 -2
View File
@@ -240,9 +240,11 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
// Use pre-created admin user (setup.Token) and regular user (setup.RegularToken)
// Both users already created by setupTestServer()
// Create a collection for admin user
// Create a collection for admin user. Unique name: the shared DB keeps
// non-"test"-named collections across runs, and UNIQUE(user_id, name)
// turns a fixed name into a 409 on the second run.
collectionReq := map[string]interface{}{
"name": "Admin Collection",
"name": "Admin Collection " + uuid.New().String()[:8],
"description": "Test collection for WebSocket test",
}
collectionBody, _ := json.Marshal(collectionReq)
+21
View File
@@ -68,6 +68,7 @@ INSERT INTO system_settings (setting_key, setting_value, description, setting_ty
('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'),
('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'),
('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'),
('registration_enabled', 'false', 'Allow users to create their own accounts (admins can always create accounts)', 'bool', NULL, NULL, FALSE, 'security'),
-- security / auth (restart required)
('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'),
('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'),
@@ -1134,9 +1135,15 @@ CREATE TABLE IF NOT EXISTS collections (
priority INT DEFAULT 100,
is_system_collection BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
UNIQUE(user_id, name)
);
-- System collections (the 5 dashboard sections) are permanent: "delete"
-- tombstones the row (deleted_at) and restore un-tombstones it in place —
-- the row id (and everything referencing it) never churn.
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
-- Index for dashboard queries
@@ -1159,6 +1166,20 @@ CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
-- Index for fast lookups
CREATE INDEX IF NOT EXISTS idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);
-- Global (all-libraries) dashboard preferences: exactly one row per user.
-- Backs the dashboard's "All Libraries" view (library_id NULL convention
-- rejected: the upsert's ON CONFLICT cannot dedupe NULLs and intent would
-- be invisible — a dedicated table makes the scope structural). System
-- and user collections are user-scoped, so collection_order/
-- hidden_collections IDs are library-independent and valid in this scope.
CREATE TABLE IF NOT EXISTS user_dashboard_preferences_global (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
hidden_collections TEXT[] DEFAULT '{}',
collection_order TEXT[] DEFAULT '{}',
items_per_section INT DEFAULT 20,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create collection_items table (which books belong to each collection)
CREATE TABLE IF NOT EXISTS collection_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+2 -1
View File
@@ -15,7 +15,8 @@ services:
# Make other volumes as needed
- ./uploads:/app/uploads
ports:
- "${DB_PORT:-5432}:${DB_PORT:-5432}"
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 30s
+1 -1
View File
@@ -14,7 +14,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
## 📋 Project Guidelines
- **[PROJECT_GUIDELINES.md](../PROJECT_GUIDELINES.md)** - Development rules and standards
- **[AGENTS.md](../../AGENTS.md)** - Agent/development rules
- Critical prohibitions
- Mandatory requirements
- Error recovery protocol
+1 -1
View File
@@ -445,7 +445,7 @@ DEBUG=true
## 📚 Additional Resources
- [Project Guidelines](../../PROJECT_GUIDELINES.md) - Development rules and standards
- [AGENTS.md](../../AGENTS.md) - Agent/development rules
- [API Reference](../API_REFERENCE.md) - Complete API documentation
- [Troubleshooting](../TROUBLESHOOTING.md) - Deployment issues
- [Go Documentation](https://go.dev/doc/)
+40 -108
View File
@@ -394,6 +394,11 @@ Authorization: Bearer <token>
## Reading Progress
Full field reference: [api/progress/](api/progress/) — and read the
[Position Contract](api/progress/position-contract.md) (verification,
healing, the OPF spine numbering hazard, offset currencies) before
writing a client.
### Get Reading Progress
```http
@@ -405,57 +410,56 @@ Authorization: Bearer <token>
```json
{
"id": "uuid",
"media_item_id": "uuid",
"user_id": "uuid",
"current_page": 45,
"total_pages": 200,
"percentage": 0.225,
"character_offset": 15432,
"epubcfi": "epubcfi(/6/4/2:15)",
"chapter": 3,
"chapter_progress": 0.5,
"last_read_at": "2026-01-31T10:00:00Z",
"percentage": 0.045,
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/58/1:598)",
"context_text": "from day to day, but there was none in which ...",
"character_offset": 16375,
"current_page": null,
"total_pages": null,
"chapter": null,
"chapter_progress": null,
"format_group": "reflowable",
"viewport_y": 0.12,
"zoom_level": 1.0
"total_characters": 592216,
"chapter_count": 1,
"last_read_at": "2026-09-26T14:52:34Z",
"last_sync_source": "koreader",
"css_selector": "body>div:nth-child(4)>p:nth-child(29)",
"anchor_href": "1984.xhtml",
"char_offset": 598
}
```
`css_selector`, `anchor_href` and `char_offset` are server-derived
restore handles, served for convertible reflowable books with a
resolvable anchor. `char_offset` is UTF-16 code units within the anchor
block's text; `character_offset` is a book-wide rune count. Resolve the
document by `anchor_href`, never by the CFI's spine step (OPF numbering
includes `linear="no"` items).
### Update Reading Progress
```http
PUT /api/media-items/{media_id}/progress
Authorization: Bearer <token>
Content-Type: application/json
{
"source": "web",
"location": {
"percentage": 0.45678,
"epubcfi": "epubcfi(/6/4/2:15)",
"character": 15432,
"chapter": 3,
"page": 89,
"total_pages": 200
},
"device_metadata": {
"device_type": "web",
"user_agent": "Mozilla/5.0..."
}
}
```
**Response** (200):
```json
{
"sync_status": "success",
"progress_updated": true,
"devices_notified": ["device-1", "device-2"],
"broadcast": true
"percentage": 0.0415,
"context_text": "was at war with one of these Powers it was generally a",
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/62/1:456)"
}
```
All fields are optional. The server verifies the submission against the
book and heals it on mismatch; the response is the stored row after
verification. A `percentage` below 0.005 is ignored while stored
progress exceeds 0.01 (first-page anti-clobber).
### Delete Reading Progress
```http
@@ -1297,82 +1301,10 @@ Authorization: Bearer <device_token>
## Universal Progress
### Get Universal Progress
```http
GET /api/progress/{book_uuid}
Authorization: Bearer <token>
```
**Response** (200):
```json
{
"book_id": "book-uuid",
"format_group": "reflowable",
"universal_progress": 0.45678,
"location_references": {
"percentage": 0.45678,
"epubcfi": "epubcfi(/6/4/2:15)",
"character": 15432,
"chapter": 3,
"chapter_progress": 0.234,
"viewport_y": 0.12
},
"device_progress": {
"koreader": {
"percentage": 0.45678,
"last_sync": "2026-01-30T20:00:00Z"
},
"kobo": {
"percentage": 45.6,
"last_sync": "2026-01-30T19:55:00Z"
},
"web": {
"display_page": 89,
"total_pages": 200,
"last_sync": "2026-01-30T20:05:00Z"
}
},
"annotations": {
"highlights": [...],
"notes": [...],
"bookmarks": [...]
},
"conflicts": [
{
"id": "conflict-uuid",
"type": "progress",
"resolved": false,
"sources": ["koreader", "kobo"]
}
]
}
```
### Update Universal Progress
```http
POST /api/progress/{book_uuid}
Authorization: Bearer <token>
Content-Type: application/json
{
"source": "web|koreader|kobo|mobile",
"location": {
"percentage": 0.45678,
"epubcfi": "epubcfi(/6/4/2:15)",
"character": 15432,
"chapter": 3,
"page": 89,
"total_pages": 200
},
"device_metadata": {
"device_type": "web",
"user_agent": "..."
}
}
```
Universal progress is the media-item progress — one row per (user,
media item) shared by every device. See [Reading Progress](#reading-progress)
and the [Position Contract](api/progress/position-contract.md). There
are no separate `/api/progress/:id` GET/POST endpoints.
## Conflicts
+7 -3
View File
@@ -117,10 +117,14 @@ See [Media Item Operations](media-items/)
## Reading Progress
See [Progress Tracking](progress/)
See [Progress Tracking](progress/) — in particular the
[Position Contract](progress/position-contract.md) (verification,
healing, the OPF spine numbering hazard, and offset currencies) before
writing a client.
- GET /api/progress/:id - Get universal progress
- POST /api/progress/:id - Update universal progress
- GET /api/media-items/:id/progress - Get reading progress + restore handles
- PUT /api/media-items/:id/progress - Submit reading progress (verified/healed server-side)
- DELETE /api/media-items/:id/progress - Delete reading progress
- GET /api/progress/:id/history - Get progress history
## Notes & Highlights
+42 -25
View File
@@ -1,9 +1,12 @@
# Get Metadata
Get metadata for a book from KOReader device.
Get a book's stored progress and annotations for a KOReader device —
the pull half of the device sync. The reference client calls this after
linking a book via [Resolve Book](resolve_book.md) and navigates to the
returned position.
**Endpoint**: `GET /api/sync/koreader/metadata/:uuid`
**Auth**: Required (Device authentication)
**Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`)
## Path Parameters
@@ -11,41 +14,55 @@ Get metadata for a book from KOReader device.
| --------- | ------------- | -------- | ----------- |
| uuid | string (UUID) | Yes | Book UUID |
## Device Authentication
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
## Request Headers
| Header | Type | Required | Description |
| ------------ | ------ | -------- | ------------------------- |
| X-Device-ID | string | Yes | Device UUID |
| X-Device-Key | string | Yes | Device authentication key |
### Example Request
```http
GET /api/sync/koreader/metadata/550e8400-e29b-41d4-a716-446655440000
X-Device-ID: 550e8400-e29b-41d4-a716-446655440000
X-Device-Key: device-auth-key
GET /api/sync/koreader/metadata/774641f9-317b-4087-8e04-53bb4392ae56
Authorization: Bearer {device_token}
```
## Response (200 OK)
```json
{
"id": "uuid",
"title": "Book Title",
"authors": ["Author Name"],
"path": "/path/to/book.epub",
"file_size": 1234567,
"modified_at": "2026-02-08T10:00:00Z"
"uuid": "774641f9-317b-4087-8e04-53bb4392ae56",
"title": "1984",
"author": "George Orwell",
"progress": {
"percentage": 0.045,
"koreader_xpointer": "/body/DocFragment[1]/body/p[29]/text().598",
"chapter": null,
"chapter_progress": null,
"page": null,
"total_pages": null
},
"annotations": {
"highlights": []
}
}
```
### Progress Object
| Field | Type | Description |
| ----- | ---- | ----------- |
| `percentage` | float | Stored position as a book fraction. |
| `koreader_xpointer` | string | The stored canonical position converted back to a CRE xpointer (UTF-16 `text().N` offset). **The device should navigate to this.** Reflowable books only. |
| `epubcfi` | string | The stored canonical CFI, when the conversion to a CRE xpointer is unavailable. Fallback after `koreader_xpointer`. |
| `character` | int | Book-wide rune offset (internal currency). |
| `chapter`, `chapter_progress` | int, float | Chapter position when known. |
| `page`, `total_pages` | int | Fixed-layout page position — the canonical locator for image-based books (CFI/xpointer are omitted for them). |
`progress` is `null` when the book has no stored progress.
The `annotations` object carries device-format highlights/bookmarks/notes
synced from other clients; its presence depends on annotation sync being
enabled.
## Error Responses
| Code | Description |
| ---- | ---------------------------- |
| Code | Description |
| ---- | ----------- |
| 400 | Invalid book UUID |
| 401 | Device authentication failed |
| 404 | Book or device not found |
| 404 | Book not found |
+58 -34
View File
@@ -1,63 +1,87 @@
# Sync Progress
Sync reading progress from KOReader device.
Push reading progress from a KOReader device.
**Endpoint**: `POST /api/sync/koreader/progress`
**Auth**: Required (Device authentication)
**Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`)
## Device Authentication
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
This is the device-native tier of the [Position
Contract](../progress/position-contract.md): the KOReader payload carries
a CRE xpointer and the server converts it to the canonical standard CFI,
verifies it against the submitted `context_text`, and heals it on
mismatch — exactly like every other client.
## Request Body
| Field | Type | Required | Description |
| --------- | ------------- | -------- | ------------------------- |
| device_id | string (UUID) | Yes | Device UUID |
| progress | array | Yes | Array of progress objects |
| Field | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `books` | array | Yes | One book object (the reference client sends a single-element array). |
| `sync_mode`| string | No | `immediate` (default) or `manual`. |
### Progress Object
### Book Object
| Field | Type | Required | Description |
| ----------- | ------- | -------- | ---------------------------------- |
| book | string | Yes | Book identifier (filename or UUID) |
| percent | float | Yes | Progress percentage (0-100) |
| page | integer | No | Current page number |
| total_pages | integer | No | Total pages in document |
| date_read | string | No | ISO 8601 timestamp of last read |
| updated_at | string | Yes | ISO 8601 timestamp |
| Field | Type | Required | Description |
| ----- | ---- | -------- | ----------- |
| `uuid` | string (UUID) | No | Bookhoard UUID, once the device has linked the book via [Resolve Book](resolve_book.md). |
| `sha256` | string | Yes | File content hash (64 hex chars) — the primary book identity. |
| `title` | string | No | Document title. |
| `authors` | array | No | Author names. |
| `percentage` | float | Yes | Position as a fraction of the book (0..1). |
| `context_text` | string | No | Up to 100 whitespace-normalized chars from the current position — enables the server's verification/healing. Strongly recommended. |
| `page` | int | No | Current page (fixed-layout books). |
| `total_pages` | int | No | Page count (fixed-layout books). |
| `epubcfi` | string | Reflowable only | **A CRE xpointer** (`/body/DocFragment[N]/body/...`), not a CFI — the field name is historical. Fixed-layout books must omit it and carry their position in `page`/`total_pages`. |
| `file_path` | string | No | Device-local file path (informational). |
| `device_info` | object | No | `{ koreader_version, device_model }`. |
### Example Request
```http
POST /api/sync/koreader/progress
Authorization: Bearer {device_token}
Content-Type: application/json
```
```json
{
"device_id": "550e8400-e29b-41d4-a716-446655440000",
"progress": [
"books": [
{
"book": "book.epub",
"percent": 75.5,
"page": 150,
"total_pages": 200,
"date_read": "2026-02-08T10:00:00Z",
"updated_at": "2026-02-08T10:00:00Z"
"uuid": "774641f9-317b-4087-8e04-53bb4392ae56",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"title": "1984",
"authors": ["George Orwell"],
"percentage": 0.045,
"context_text": "from day to day, but there was none in which Goldstein was not the principal figure. He was the prim",
"page": 14,
"total_pages": 311,
"epubcfi": "/body/DocFragment[1]/body/p[29]/text().598",
"device_info": {
"koreader_version": "v2026.07.1",
"device_model": "emulator"
}
}
]
],
"sync_mode": "immediate"
}
```
## Response (200 OK)
## Response (202 Accepted)
```json
{
"message": "Progress synced successfully",
"synced_count": 1
"sync_status": "ok",
"books_synced": 1,
"timestamp": "2026-09-26T21:25:09Z"
}
```
Per-book results and any detected sync conflicts are carried in
`book_results` and `conflicts` when present.
## Error Responses
| Code | Description |
| ---- | ---------------------------- |
| Code | Description |
| ---- | ----------- |
| 400 | Invalid request format |
| 401 | Device authentication failed |
| 400 | Invalid request data |
| 404 | Device not found |
| 500 | Database error |
@@ -0,0 +1,35 @@
# Delete Media Item Progress
Delete the stored reading progress for a media item.
**Endpoint**: `DELETE /api/media-items/:id/progress`
**Auth**: Required (Bearer token)
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | ---------------- |
| id | string (UUID) | Yes | Media item UUID |
### Example Request
```http
DELETE /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
```
## Response (200 OK)
```json
{
"message": "reading progress deleted"
}
```
## Error Responses
| Code | Description |
| ---- | ----------- |
| 400 | Invalid media item id |
| 401 | Invalid or expired token |
| 500 | Database error |
@@ -1,36 +0,0 @@
# Delete Reading Progress
Delete reading progress for a media item.
**Endpoint**: `DELETE /api/media-items/{media_id}/progress`
**Auth**: Required
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------- |
| media_id | string | Yes | Media item UUID |
## Request Headers
| Header | Type | Required | Description |
| ------------- | ------ | -------- | ------------ |
| Authorization | string | Yes | Bearer token |
### Example Request
```http
DELETE /api/media-items/uuid/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## Response (204 No Content)
Progress deleted successfully.
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 401 | Invalid or expired token |
| 404 | Media item not found |
@@ -0,0 +1,98 @@
# Get Media Item Progress
Get the stored reading progress for a media item, plus the
server-derived restore handles for reflowable books.
**Endpoint**: `GET /api/media-items/:id/progress`
**Auth**: Required (Bearer token)
See [Position Contract](position-contract.md) for the semantics of every
field — what is verified, what the currencies are, and how clients
should restore.
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | --------------- |
| id | string (UUID) | Yes | Media item UUID |
### Example Request
```http
GET /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
```
## Response (200 OK) — no progress stored
When the item has no progress row, an empty fixed-layout-style stub is
returned (not 404):
```json
{
"current_page": 0,
"total_pages": null
}
```
## Response (200 OK) — progress stored
```json
{
"id": "e8239659-0ed6-42ae-a946-8440d7655b42",
"media_item_id": "774641f9-317b-4087-8e04-53bb4392ae56",
"user_id": "1b64992e-3408-4d84-9e03-e2dc4950e1dd",
"current_page": null,
"total_pages": null,
"last_read_at": "2026-09-26T14:52:34.806446Z",
"percentage": 0.045,
"character_offset": 16375,
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/58/1:598)",
"chapter": null,
"chapter_progress": null,
"format_group": "reflowable",
"total_characters": 592216,
"chapter_count": 1,
"last_sync_device": "web",
"last_sync_source": "koreader",
"last_sync_timestamp": "2026-09-26T14:52:34.806446Z",
"css_selector": "body>div:nth-child(4)>p:nth-child(29)",
"anchor_href": "1984.xhtml",
"char_offset": 598,
"context_text": "from day to day, but there was none in which Goldstein was not the principal figure. He was the prim"
}
```
### Field Reference
Base fields (always present when a row exists):
| Field | Type | Description |
| ----- | ---- | ----------- |
| `percentage` | float | Position as a fraction of the whole book (0..1). |
| `epubcfi` | string | The stored canonical standard CFI. **Spine steps index the OPF spine as written, including `linear="no"` items — do not resolve them against a readium reading order.** See the [Position Contract](position-contract.md#spine-numbering-hazard--read-this-before-parsing-a-stored-cfi). |
| `context_text` | string | The stored verification context (≤100 whitespace-normalized chars from the anchor). |
| `character_offset` | int | Book-wide rune offset. Internal currency — consistent with `total_characters`. |
| `current_page`, `total_pages` | int | Fixed-layout page position (null for reflowable). |
| `chapter`, `chapter_progress` | int, float | Chapter index and within-chapter fraction, when known. |
| `format_group` | string | `reflowable`, `fixed_layout`, `comic_archive`, … Gates the restore handles. |
| `total_characters`, `chapter_count` | int | Book metrics, for client-side fraction math. |
| `last_sync_device`, `last_sync_source`, `last_sync_timestamp` | — | Which client last wrote the row. |
Restore handles (conditional — served only for convertible reflowable
books whose stored anchor re-resolves at GET time):
| Field | Type | Description |
| ----- | ---- | ----------- |
| `anchor_href` | string | The spine document containing the anchor (e.g. `1984.xhtml`). **Resolve your resource by this**, never by the CFI's spine step. |
| `css_selector` | string | Body-relative chain to the anchor's block element. |
| `char_offset` | int | Anchor offset within the block's concatenated text, in UTF-16 code units. |
| `epubcfi` | string | Re-served (possibly healed) canonical CFI — present whenever re-verification produced one. |
## Error Responses
| Code | Description |
| ---- | ----------- |
| 400 | Invalid media item id |
| 401 | Invalid or expired token |
| 500 | Database error |
@@ -1,52 +0,0 @@
# Get Reading Progress
Retrieve reading progress for a specific media item.
**Endpoint**: `GET /api/media-items/{media_id}/progress`
**Auth**: Required
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------- |
| media_id | string | Yes | Media item UUID |
## Request Headers
| Header | Type | Required | Description |
| ------------- | ------ | -------- | ------------ |
| Authorization | string | Yes | Bearer token |
### Example Request
```http
GET /api/media-items/uuid/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## Response (200 OK)
```json
{
"media_item_id": "uuid",
"user_id": "uuid",
"current_page": 45,
"total_pages": 200,
"percentage": 0.225,
"character_offset": 15432,
"epubcfi": "epubcfi(/6/4/2:15)",
"chapter": 3,
"chapter_progress": 0.5,
"last_read_at": "2026-01-31T10:00:00Z",
"format_group": "reflowable",
"viewport_y": 0.12,
"zoom_level": 1.0
}
```
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 401 | Invalid or expired token |
| 404 | Media item not found |
@@ -1,50 +0,0 @@
# Get Universal Progress
Get universal (device-agnostic) reading progress for a media item.
**Endpoint**: `GET /api/progress/:id`
**Auth**: Required
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | --------------- |
| id | string (UUID) | Yes | Media item UUID |
## Request Headers
| Header | Type | Required | Description |
| ------------- | ------ | -------- | ------------ |
| Authorization | string | Yes | Bearer token |
### Example Request
```http
GET /api/progress/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## Response (200 OK)
```json
{
"media_item_id": "uuid",
"percentage": 75.5,
"position": 1234,
"page": 150,
"total_pages": 200,
"finished": false,
"updated_at": "2026-02-08T10:00:00Z",
"device": {
"id": "device-uuid",
"name": "My Kobo"
}
}
```
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 401 | Invalid or expired token |
| 404 | Media item not found |
@@ -0,0 +1,135 @@
# Position Contract
How reading positions are represented, submitted, verified, and restored
across clients. This is the contract every client speaks — readium-based
apps, web (foliate), KOReader, and Kobo devices alike.
**The server is the position authority.** Every submission is
independently verified against the book itself before it is stored; a
client whose locator math is wrong cannot poison the stored position.
In exchange, the server hands back handles clients can apply directly,
so they never have to parse or trust CFIs themselves.
## The three-tier submission
Clients submit what their renderer can reliably observe. All three tiers
are accepted on `PUT /api/media-items/:id/progress` (and on the KOReader
device endpoints):
| Tier | Field | Currency | Notes |
| ------ | -------------- | ----------------------------------------------------- | ----- |
| 1 | `percentage` | Float 0..1 of the whole book | Universal. The only field every client can supply. |
| 2 | `context_text` | Up to 100 chars, whitespace-normalized, starting at the anchor | The verification anchor. The server extracts the text at the submitted structural anchor and compares. |
| 3 | `epubcfi` | Standard wrapped CFI, terminals in UTF-16 code units | Optional structural anchor. KOReader submits a CRE xpointer and the server converts it. |
Tier 2 is what makes the system self-correcting: percentages alone
cannot distinguish "the reader's locator is right" from "the reader
silently reported wherever it is currently scrolled".
## Verification and healing (ingest side)
On every progress save for a convertible reflowable book that carries a
`context_text`, the server:
1. Parses the submitted `epubcfi` (if any) and resolves it against the
book's own XHTML.
2. Extracts the text at the resolved anchor and cross-checks it with the
submitted `context_text`.
3. On a mismatch — or an unresolvable anchor, or no anchor at all —
heals the position by text search, using the submitted `percentage`
to disambiguate repeated phrases. The healed CFI and a recomputed
percentage are stored in place of the submitted ones.
4. Refreshes the book-wide `character_offset` column from the verified
anchor on every verified save, so it never goes stale behind the
anchor.
If verification fails outright (book file unreadable, context not found
and percentage cannot disambiguate), the error is logged and the
submission is stored as-is — verification never rejects a save, it only
corrects.
## The restore handles
`GET /api/media-items/:id/progress` serves, alongside the raw stored
fields, the server-derived handles for reflowable books with a
resolvable anchor:
| Field | Currency | Meaning |
| ------------- | ----------------------- | ------- |
| `anchor_href` | — | The spine document the anchor lives in (e.g. `1984.xhtml`). **This is how a client finds the right resource.** |
| `css_selector`| — | Body-relative `tag:nth-child(k)` chain of the anchor's block element (e.g. `body>div:nth-child(4)>p:nth-child(29)`). |
| `char_offset` | UTF-16 code units | Offset of the anchor within the concatenated text of that block. |
| `epubcfi` | UTF-16 terminals | The stored canonical CFI — re-served healed if the GET-time re-verification improved it. |
| `context_text`| — | The stored verification context (≤100 normalized chars from the anchor). |
**Recommended restore sequence for a client:**
1. Open the book and jump to the stored `percentage` (coarse floor).
2. Resolve `anchor_href` against your own spine (an ends-with match on
document hrefs) and open that document if you are not already there.
3. Query `css_selector` in that document, walk its text nodes counting
UTF-16 units to `char_offset`, and scroll that position into view.
4. If the anchor cannot be measured (renderer-specific laziness), the
percentage floor stands.
## SPINE NUMBERING HAZARD — read this before parsing a stored CFI
**`epubcfi` spine steps index the OPF spine AS WRITTEN, including
`linear="no"` items.** Several rendering engines (notably readium)
number their reading order EXCLUDING `linear="no"` items. When a book's
cover (or any other item) is `linear="no"`, the two numberings differ by
a constant offset from that item onward — a client resolving a stored
CFI's spine step against its own numbering lands in the WRONG DOCUMENT.
This is not hypothetical: "1984" epubs commonly have a `linear="no"`
cover, which makes readium spine 0 = OPF spine 1. The server heals such
numbering mismatches at ingest (that is what the context check is for),
but the durable rule for client authors is:
> **Never resolve a stored CFI's spine step yourself.** Resolve the
> document by `anchor_href`, then land with `css_selector` +
> `char_offset`. Treat the CFI as opaque server currency.
## Offset currencies
Two counting systems are in play, and they are deliberately kept apart:
| Quantity | Currency | Why |
| -------- | -------- | --- |
| CFI terminal offsets (`…/1:456`) | UTF-16 code units | The EPUB CFI spec, and what every client observes (JavaScript `.length`). |
| `char_offset` (block-relative handle) | UTF-16 code units | Same reason — clients walk DOM text with JS semantics. |
| KOReader CRE `text().N` offsets | UTF-16 code units | crengine is UCS-16 internally. |
| `character_offset` (book-wide column) | Unicode runes | Internal, consistent with `total_characters` and the percentage derivations. |
For all-BMP text the two currencies are identical. They diverge on
astral-plane characters (emoji, rare CJK ideographs): one rune, two
UTF-16 units. The server converts at every wire boundary; internal
arithmetic never crosses.
## `context_text` rules
- Starts at the anchor position (it may begin mid-word).
- Whitespace-normalized (all runs of whitespace collapse to single
spaces).
- At most 100 characters.
- Comparison is containment-based (client and server suffixes of the
same block verify in either direction); contexts shorter than 12
chars never match.
## Engine-specific notes
- **readium-based clients** (the Android app): submit
percentage + `context_text` + a CFI generated from the laid-out
WebView. Their locally-generated CFI spine steps use readium
numbering — the server heals the difference; nothing to do.
- **Web (foliate)**: submit all three tiers; foliate CFIs are the same
currency the server stores.
- **KOReader**: submits a CRE xpointer in the `epubcfi` field of the
device payload (historical field name; it is a CRE xpointer, not a
CFI). The server converts CRE → canonical at ingest and canonical →
CRE on pull (`koreader_xpointer` in the metadata response).
- **Kobo**: submits kepub CFI locators, converted server-side the same
way.
- **Fixed-layout content** (PDF/CBZ): the page index is the canonical
locator; CFI/xpointer are meaningless and neither submitted nor
served.
@@ -0,0 +1,91 @@
# Update Media Item Progress
Submit reading progress for a media item. This is the position-authority
ingest point: the server independently verifies the submission against
the book and heals it when the client's projection is wrong.
**Endpoint**: `PUT /api/media-items/:id/progress`
**Auth**: Required (Bearer token)
**Content-Type**: `application/json`
See the [Position Contract](position-contract.md) for the three-tier
submission model, the verification/healing semantics, and the offset
currencies.
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | ---------------- |
| id | string (UUID) | Yes | Media item UUID |
## Request Body
All fields are optional; submit what your renderer can observe.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `percentage` | float | Position as a fraction of the whole book (0..1). Tier 1 — the universal field. |
| `context_text` | string | Up to 100 whitespace-normalized chars starting at the anchor. Tier 2 — enables verification and healing. |
| `epubcfi` | string | Standard wrapped CFI (UTF-16 terminals). Tier 3 — the structural anchor. Note: the server re-derives the stored canonical CFI; a client's own spine numbering is healed if it disagrees with the OPF spine. |
| `character_offset` | int | Book-wide rune offset. Accepted but recomputed server-side from the verified anchor on every verified save. |
| `current_page`, `total_pages` | int | Fixed-layout position. For fixed-layout formats the page index is the canonical locator. |
| `chapter`, `chapter_progress` | int, float | Chapter index and within-chapter fraction. |
| `reading_mode` | string | `paged` or `scrolled` (fixed-layout reader state). |
| `zoom_level`, `scroll_position_x`, `scroll_position_y` | float | Fixed-layout viewport state. |
### Example Request (reflowable, all three tiers)
```http
PUT /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json
```
```json
{
"percentage": 0.0415,
"context_text": "was at war with one of these Powers it was generally at peace with the other. But what was strange w",
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/62/1:456)"
}
```
### Example Request (fixed-layout)
```json
{
"percentage": 0.15,
"current_page": 30,
"total_pages": 194,
"reading_mode": "paged"
}
```
## Behavior
- The submission is verified against the book's XHTML when
`context_text` is present and the book is convertible reflowable: the
CFI is resolved, the text at the anchor is compared with
`context_text`, and mismatches heal by text search (percentage
disambiguates repeats). See [Position Contract](position-contract.md).
- **Anti-clobber guard**: a submission with `percentage < 0.005` is
ignored with `{"status": "ignored"}` when the stored row already holds
a percentage above `0.01` — re-opening a book at its first page does
not wipe real progress.
- The response is the stored row after verification, including the
healed `epubcfi` and the refreshed `character_offset` when
verification ran.
## Response (200 OK)
The saved progress row (same shape as
[GET](get_media_progress.md), minus the GET-time handles). The
`epubcfi` and `percentage` in the response are the server-verified
values, which may differ from the submitted ones when healing occurred.
## Error Responses
| Code | Description |
| ---- | ----------- |
| 400 | Invalid request data |
| 401 | Invalid or expired token |
| 500 | Database error |
@@ -1,68 +0,0 @@
# Update Reading Progress
Update reading progress for a media item. This will sync across all devices via WebSocket.
**Endpoint**: `PUT /api/media-items/{media_id}/progress`
**Auth**: Required
**Content-Type**: `application/json`
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------- |
| media_id | string | Yes | Media item UUID |
## Request Body
| Field | Type | Required | Description |
| --------------------------- | ------- | -------- | ------------------------------------------------- |
| source | string | Yes | Progress source (e.g., "web", "koreader", "kobo") |
| location | object | Yes | Location information |
| location.percentage | float | No | Progress percentage (0-1) |
| location.epubcfi | string | No | EPUB CFI location |
| location.character | integer | No | Character offset |
| location.chapter | integer | No | Chapter number |
| location.page | integer | No | Current page |
| location.total_pages | integer | No | Total pages |
| device_metadata | object | No | Device metadata |
| device_metadata.device_type | string | No | Device type |
| device_metadata.user_agent | string | No | User agent string |
### Example Request
```json
{
"source": "web",
"location": {
"percentage": 0.45678,
"epubcfi": "epubcfi(/6/4/2:15)",
"character": 15432,
"chapter": 3,
"page": 89,
"total_pages": 200
},
"device_metadata": {
"device_type": "web",
"user_agent": "Mozilla/5.0..."
}
}
```
## Response (200 OK)
```json
{
"sync_status": "success",
"progress_updated": true,
"devices_notified": ["device-1", "device-2"],
"broadcast": true
}
```
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 400 | Invalid location data |
| 401 | Invalid or expired token |
| 404 | Media item not found |
@@ -1,56 +0,0 @@
# Update Universal Progress
Update universal reading progress for a media item.
**Endpoint**: `POST /api/progress/:id`
**Auth**: Required
**Content-Type**: `application/json`
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | --------------- |
| id | string (UUID) | Yes | Media item UUID |
## Request Body
| Field | Type | Required | Description |
| ---------- | ------------- | -------- | ------------------------------------------- |
| percentage | float | No | Progress percentage (0-100) |
| position | integer | No | Current position in bytes |
| page | integer | No | Current page number |
| finished | boolean | No | Whether the book is finished |
| device_id | string (UUID) | No | Device UUID (optional, for tracking source) |
### Example Request
```json
{
"percentage": 75.5,
"position": 1234,
"page": 150,
"finished": false,
"device_id": "device-uuid"
}
```
## Response (200 OK)
```json
{
"media_item_id": "uuid",
"percentage": 75.5,
"position": 1234,
"page": 150,
"finished": false,
"updated_at": "2026-02-08T10:00:00Z"
}
```
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 400 | Invalid request data |
| 401 | Invalid or expired token |
| 404 | Media item not found |
+8 -3
View File
@@ -30,10 +30,15 @@ The Bookhoard scanner provides comprehensive library management for ebooks, comi
| -------- | ------------------------ |
| EPUB | `.epub` |
| PDF | `.pdf` |
| Kindle | `.mobi`, `.azw`, `.azw3` |
| Kindle | `.mobi` |
| Text | `.txt`, `.rtf` |
| Document | `.doc`, `.docx` |
| Other | `.lit`, `.fb2`, `.pdb` |
| Document | `.docx` |
| Other | `.fb2`, `.pdb` |
DOC (Word 97-2003 binary) and LIT (Microsoft Reader) are deliberately
unsupported: no credible JavaScript parser exists for either, and
compromising on fidelity is against the project's format goals. Files
with these extensions are not scanned.
### Comics
+1 -1
View File
@@ -18,7 +18,7 @@ Initiate a one-time scan of a library for ebooks, manga, or comics.
The scanner automatically detects and processes files based on the library type:
**Ebooks:** .epub, .pdf, .mobi, .azw, .azw3, .txt, .rtf, .doc, .docx, .lit, .fb2, .pdb
**Ebooks:** .epub, .pdf, .mobi, .txt, .rtf, .docx, .fb2, .pdb
**Comics:** .cbz, .cbr, .cb7, .cbt, .pdf
+6
View File
@@ -73,6 +73,12 @@ Browse individual endpoint documentation with interactive API Explorer:
## 🛠 Implementation Guides
### Library Visibility
- **[Library Visibility](library-visibility.md)** - Per-user library
visibility: the family-lens rule. New media endpoints MUST thread the
visible-library set (helpers, SQL pattern, series per-library).
### Calibre Integration
- **[Calibre OPF Implementation](../development/calibre-opf-implementation.md)** - Technical implementation details
+54
View File
@@ -0,0 +1,54 @@
# Library Visibility (family-lens rule)
Every endpoint that returns media metadata or content must respect
per-user **library visibility**: the `library_visibility` table maps
`(user_id, library_id, is_visible)` and defaults to **ALLOW** — every
user sees every library unless an admin explicitly hides one from them.
The point of the system is the family case: an admin hides the adult
library from a child's account, and no trace of that library (titles,
covers, series, page images) may reach the child's account.
## The rule for new endpoints
Any endpoint that lists, filters, or details media/series/books:
1. Filter by the requesting user's visible-library set.
2. Reject an explicit `library_id` outside that set with **403** —
never silently ignore or fall back.
3. Content endpoints (file bytes, page images) gate per item, like
`ReaderHandler.ensureItemVisible`.
If you forget, hidden libraries leak. This exact leak shipped: the
series endpoints were name-only across ALL libraries and the PDF
thumbnail endpoint served page images of hidden books (fixed 2026-10-13,
bookhoard `431fec0`).
## Helpers — do not hand-roll
- `(*MediaHandler).visibleLibrarySet` /
`(*SeriesHandler).visibleLibrarySet` — returns a membership map plus
the id slice (map for param checks, slice for SQL `ANY()`).
- `router.visibleLibraryIDsFor` — same for SSR routes.
- `resolveLibrary` (router/helpers.go) only honors a requested
`library_id` that is inside the caller's visible set.
- SQL pattern (see `ListMediaItems`, `GetDistinctSeries`,
`SearchMediaItems`):
```sql
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL
OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
```
## Known-safe surfaces
- OPDS device catalogs filter by the device owner's visible libraries.
- Search (`SearchMediaItems`) is visibility-scoped.
- File download, reader page render, and page content gate per item.
## Series are per-library
Series are grouped by `(library_id, series)` — the same series name in
two libraries is two entries (manga adaptations, LN splits). Do not
re-merge them cross-library. `/api/series/books` REQUIRES a visible
`library_id`; `/api/series` without one aggregates only the caller's
visible libraries.
+2 -2
View File
@@ -63,7 +63,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
**[Contributing Portal](contributing/contributing.md)** - Development workflow
- [Development Guide](contributing/development.md) - Architecture, setup, testing
- [../PROJECT_GUIDELINES.md](../PROJECT_GUIDELINES.md) - Development rules and standards
- [../../AGENTS.md](../../AGENTS.md) - Development rules and standards
---
@@ -143,7 +143,7 @@ When adding new features:
3. **Backend changes** → Update [Development Guide](contributing/development.md)
4. **Deployment changes** → Update [Operations Portal](operations/operations.md)
Keep [../PROJECT_GUIDELINES.md](../PROJECT_GUIDELINES.md) in mind for documentation standards.
Keep [../../AGENTS.md](../../AGENTS.md) in mind for documentation standards.
---
+1 -1
View File
@@ -124,7 +124,7 @@ The following Calibre metadata is **not** imported (by design):
Bookhoard imports Calibre metadata for **all file types**:
- 📚 **Ebooks**: EPUB, MOBI, AZW3, PDF, TXT, etc.
- 📚 **Ebooks**: EPUB, MOBI, PDF, TXT, etc.
- 🎧 **Audiobooks**: MP3, M4B, M4A, etc.
- 📰 **Comics**: CBZ, CBR, CB7, CBT, etc.
- 📄 **Documents**: PDF, DOCX, etc.
+43
View File
@@ -0,0 +1,43 @@
# Android App Setup Guide
The Bookhoard Android app is the native mobile client: browse your libraries, read EPUBs, PDFs, comics and manga, and sync progress, highlights, bookmarks and notes with the server.
## Prerequisites
- ✅ A Bookhoard instance running and reachable from your phone's network
- ✅ The Bookhoard APK installed on your phone (Android 8.0+ / API 26+)
- ✅ Your phone connected to the same network as the server (for a self-hosted LAN setup)
## Installing the app
The app is distributed as a sideloaded APK:
1. Copy the APK to your phone (USB, or any file-sync you trust)
2. Tap the APK to install — approve the "install unknown apps" prompt for the app you installed from (file manager, browser, etc.)
3. Upgrades install straight over the existing app and keep your data (login, downloads, reading state)
## First run
1. **Server URL** — enter your server's address. For a self-hosted LAN setup that is `http://<desktop-LAN-IP>:8765` (plain HTTP is expected here and fully supported; check the server machine's firewall allows port 8765 from your LAN)
2. **Log in** with your Bookhoard account
3. **Device registration** happens automatically — the app registers itself as a synced device so progress and annotations sync under your account
## Android 16+: the local-network permission
On Android 16 and newer, apps need explicit permission to talk to devices on your local network (and to non-HTTPS local addresses in general). **If the permission is missing, the app's logins to a LAN server time out with no visible cause** — the phone silently drops the traffic.
- The app **asks for the permission by itself** during setup, as soon as you enter a local server address — grant it when prompted
- If it was denied (or you missed the prompt): **Settings → Apps → Bookhoard → Permissions → "Access local network devices" → Allow**, then try again
- Servers reached over the public internet (HTTPS) are not affected by this permission
## Troubleshooting login failures
The login screen prints the underlying error after "Could not reach server: …" — read it to narrow the cause:
| Error | Meaning | What to check |
|---|---|---|
| `SocketTimeoutException` | The phone sent nothing that reached the server | On Android 16+ this is most often the **local-network permission** (above). Otherwise: wrong IP, phone on a different network/VLAN, or server down |
| `ConnectException` (connection refused/blocked) | The phone reached the machine but nothing answered | Server container down, or a firewall rejecting port 8765 |
| `UnknownHostException` | The hostname didn't resolve | Typo in the server URL, or a DNS/name issue (raw IPs avoid this) |
To verify the server is reachable from the phone at all, open the same URL in the phone's browser — the browser is not subject to the per-app local-network permission, so if the browser works but the app times out on Android 16+, it is the permission.
+6
View File
@@ -6,6 +6,12 @@ Welcome to the Bookhoard user documentation. This section contains guides for us
Learn how to configure your e-reader devices to sync with Bookhoard:
- **[Android App Guide](devices/android-app.md)** - The native Android client
- Installing and upgrading the APK
- Connecting to a self-hosted server over LAN
- The Android 16+ local-network permission
- Troubleshooting login failures
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader
- Installation on Kindle/Kobo/PocketBook
- Plugin setup with server-side device approval
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.31.0
package database
+10 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.31.0
package database
@@ -31,6 +31,7 @@ type Collections struct {
Priority pgtype.Int4 `db:"priority" json:"priority"`
IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
}
type DeviceCatalogs struct {
@@ -538,6 +539,14 @@ type UserDashboardPreferences struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type UserDashboardPreferencesGlobal struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
HiddenCollections []string `db:"hidden_collections" json:"hidden_collections"`
CollectionOrder []string `db:"collection_order" json:"collection_order"`
ItemsPerSection pgtype.Int4 `db:"items_per_section" json:"items_per_section"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type Users struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
+39 -9
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.31.0
package database
@@ -30,6 +30,7 @@ type Querier interface {
// Cleanup expired OPDS tokens
CleanupExpiredOpdsTokens(ctx context.Context) error
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
ClearCollectionItems(ctx context.Context, collectionID pgtype.UUID) error
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
@@ -79,6 +80,11 @@ type Querier interface {
// MEDIA ITEM FORMATS QUERIES
// Create media item format
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
// API create path: race-safe create-or-detect. On a duplicate
// (library_id, file_path) this inserts NOTHING and returns no row
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row
// and a duplicate flag instead of pretending it created something.
CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error)
// Media Notes queries
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error)
@@ -125,12 +131,12 @@ type Querier interface {
DeleteKoboEntitlement(ctx context.Context, arg DeleteKoboEntitlementParams) error
DeleteLibrary(ctx context.Context, id pgtype.UUID) error
DeleteLibraryFolder(ctx context.Context, arg DeleteLibraryFolderParams) (LibraryFolders, error)
DeleteMediaBookmark(ctx context.Context, id pgtype.UUID) error
DeleteMediaHighlight(ctx context.Context, id pgtype.UUID) error
DeleteMediaBookmark(ctx context.Context, arg DeleteMediaBookmarkParams) error
DeleteMediaHighlight(ctx context.Context, arg DeleteMediaHighlightParams) error
DeleteMediaItem(ctx context.Context, id pgtype.UUID) error
// Delete media item format
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
DeleteMediaNote(ctx context.Context, id pgtype.UUID) error
DeleteMediaNote(ctx context.Context, arg DeleteMediaNoteParams) error
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
@@ -174,6 +180,10 @@ type Querier interface {
// CAROUSEL-STYLE DASHBOARD
// ============================================
// Dashboard preferences queries
// The handler routes by scope: a Valid library_id targets the per-library
// table below; an absent one targets user_dashboard_preferences_global
// (the "All Libraries" view). Never pass NULL here — the loose NULL match
// was removed because it returned an arbitrary library's row.
GetDashboardPreferences(ctx context.Context, arg GetDashboardPreferencesParams) (UserDashboardPreferences, error)
GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error)
GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error)
@@ -195,11 +205,16 @@ type Querier interface {
// Get device shelf mappings
GetDeviceShelfMappings(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceShelfMappingsRow, error)
GetDictionaryEntry(ctx context.Context, word string) (DictionaryCache, error)
// Series are grouped PER LIBRARY: the same series name in two libraries
// (novels vs manga adaptations, LN splits) stays two entries. Visible
// filter enforces per-user library visibility at the source.
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
GetDistinctSeriesCount(ctx context.Context, libraryID pgtype.UUID) (int32, error)
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error)
// Global (all-libraries) scope: exactly one row per user.
GetGlobalDashboardPreferences(ctx context.Context, userID pgtype.UUID) (UserDashboardPreferencesGlobal, error)
GetHashConflict(ctx context.Context, id pgtype.UUID) (HashConflicts, error)
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
@@ -243,6 +258,7 @@ type Querier interface {
// ============================================
GetMediaItemByFilePathForSync(ctx context.Context, filePath string) (MediaItems, error)
GetMediaItemByKoboContentId(ctx context.Context, koboContentID pgtype.Text) (MediaItems, error)
GetMediaItemByLibraryAndPath(ctx context.Context, arg GetMediaItemByLibraryAndPathParams) (MediaItems, error)
// Get media item by OPF identifier
GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier pgtype.Text) (MediaItems, error)
// Get media item by OPF UUID
@@ -293,12 +309,21 @@ type Querier interface {
GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error)
GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error)
GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error)
GetSeriesBooks(ctx context.Context, series pgtype.Text) ([]MediaItems, error)
// Scoped to ONE library: same-name series in different libraries are
// different stories (manga adaptations, LN splits). Callers must pass a
// library_id and verify the requester can see that library.
GetSeriesBooks(ctx context.Context, arg GetSeriesBooksParams) ([]MediaItems, error)
GetSeriesCovers(ctx context.Context, arg GetSeriesCoversParams) ([]GetSeriesCoversRow, error)
GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, error)
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error)
GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (GetSyncQueueStatsRow, error)
// System collections are permanent: identity is (user_id, query_type).
// Restore/reset operate IN PLACE (the row id — and everything referencing
// it: shelf mappings, prefs order, links — never churns). Deleted sections
// are TOMBSTONED (deleted_at) and stay invisible until restored.
// Unfiltered by design: restore/seed must resolve tombstoned rows too.
GetSystemCollectionByQueryType(ctx context.Context, arg GetSystemCollectionByQueryTypeParams) (Collections, error)
// Dashboard collections queries
GetSystemCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error)
// SYSTEM CONFIG QUERIES
@@ -419,6 +444,9 @@ type Querier interface {
RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error)
RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (int64, error)
RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNoteByIDParams) (int64, error)
// Full reset, in place: canonical metadata, back on the dashboard,
// un-tombstoned. Item assignments are cleared by the caller ("full").
RestoreSystemCollectionRow(ctx context.Context, arg RestoreSystemCollectionRowParams) (Collections, error)
RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error
RevokeDevice(ctx context.Context, id pgtype.UUID) error
// Revoke OPDS token
@@ -438,11 +466,12 @@ type Querier interface {
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
TombstoneMediaBookmarkByID(ctx context.Context, id pgtype.UUID) error
TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error
TombstoneMediaHighlightByDedupKey(ctx context.Context, arg TombstoneMediaHighlightByDedupKeyParams) error
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
TombstoneMediaHighlightByID(ctx context.Context, arg TombstoneMediaHighlightByIDParams) error
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
// Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
@@ -503,6 +532,7 @@ type Querier interface {
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
File diff suppressed because it is too large. Load diff
+116 -23
View File
@@ -1,7 +1,7 @@
-- name: CreateUser :one
INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, email, username, theme, first_name, last_name, role, created_at, updated_at;
INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role, timezone)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, email, username, theme, first_name, last_name, role, timezone, created_at, updated_at;
-- name: GetUserByEmail :one
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1;
@@ -154,6 +154,19 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
RETURNING *;
-- name: CreateMediaItemIfNew :one
-- API create path: race-safe create-or-detect. On a duplicate
-- (library_id, file_path) this inserts NOTHING and returns no row
-- (pgx.ErrNoRows) — the handler then answers 200 with the existing row
-- and a duplicate flag instead of pretending it created something.
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
ON CONFLICT (library_id, file_path) DO NOTHING
RETURNING *;
-- name: GetMediaItemByLibraryAndPath :one
SELECT * FROM media_items WHERE library_id = $1 AND file_path = $2;
-- name: GetMediaItem :one
SELECT * FROM media_items WHERE id = $1;
@@ -164,6 +177,7 @@ JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
-- name: ListMediaItemsByLibrary :many
@@ -628,6 +642,8 @@ WHERE COALESCE(lv.is_visible, true) = true
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
-- Fuzzy genre filter
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
-- Fuzzy publisher filter
AND (sqlc.narg('publisher_filter') = '' OR word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')) > 0.3)
-- Fuzzy language filter
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
-- Tags filter (NEW - fuzzy match against tags array)
@@ -702,6 +718,7 @@ ORDER BY
ELSE 0 END,
word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')),
word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')),
word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')),
(SELECT MAX(word_similarity(sqlc.narg('tags_filter'), tag))
FROM unnest(mi.tags_search) AS tag)
)
@@ -854,11 +871,11 @@ UPDATE media_notes SET
content = $2,
position = $3,
updated_at = NOW()
WHERE id = $1
WHERE id = $1 AND user_id = $4
RETURNING *;
-- name: DeleteMediaNote :exec
DELETE FROM media_notes WHERE id = $1;
DELETE FROM media_notes WHERE id = $1 AND user_id = $2;
-- Media Highlights queries
-- name: CreateMediaHighlight :one
@@ -880,11 +897,11 @@ UPDATE media_highlights SET
color = $5,
note_id = $6,
updated_at = NOW()
WHERE id = $1
WHERE id = $1 AND user_id = $7
RETURNING *;
-- name: DeleteMediaHighlight :exec
DELETE FROM media_highlights WHERE id = $1;
DELETE FROM media_highlights WHERE id = $1 AND user_id = $2;
-- ============================================
-- ANNOTATION SYNC QUERIES (highlights)
@@ -927,7 +944,7 @@ UPDATE media_highlights SET
updated_at = NOW(),
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
WHERE id = $1 AND user_id = $15
RETURNING *;
-- name: TombstoneMediaHighlightByDedupKey :exec
@@ -942,7 +959,7 @@ UPDATE media_highlights SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
WHERE id = $1 AND user_id = $2;
-- name: PurgeExpiredHighlightTombstones :exec
DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1;
@@ -984,7 +1001,7 @@ UPDATE media_notes SET
updated_at = NOW(),
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
WHERE id = $1 AND user_id = $13
RETURNING *;
-- name: TombstoneMediaNoteByDedupKey :exec
@@ -999,7 +1016,7 @@ UPDATE media_notes SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
WHERE id = $1 AND user_id = $2;
-- name: PurgeExpiredNoteTombstones :exec
DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1;
@@ -1042,7 +1059,7 @@ UPDATE media_bookmarks SET
created_at = created_at,
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
WHERE id = $1 AND user_id = $14
RETURNING *;
-- name: TombstoneMediaBookmarkByDedupKey :exec
@@ -1057,7 +1074,7 @@ UPDATE media_bookmarks SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
WHERE id = $1 AND user_id = $2;
-- name: PurgeExpiredBookmarkTombstones :exec
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1;
@@ -2148,7 +2165,7 @@ SELECT * FROM collections WHERE id = $1;
-- Get collections by user
-- name: GetCollectionsByUser :many
SELECT * FROM collections WHERE user_id = $1 ORDER BY created_at DESC;
SELECT * FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC;
-- Get collection with book count
-- name: GetCollectionWithBookCount :one
@@ -2205,7 +2222,8 @@ ORDER BY ci.added_at DESC;
SELECT c.*
FROM collections c
JOIN collection_items ci ON c.id = ci.collection_id
WHERE ci.media_item_id = $1;
WHERE ci.media_item_id = $1
AND c.deleted_at IS NULL;
-- Check if book is in collection
-- name: IsBookInCollection :one
@@ -2432,9 +2450,13 @@ LIMIT $1 OFFSET $2;
-- ============================================
-- Dashboard preferences queries
-- The handler routes by scope: a Valid library_id targets the per-library
-- table below; an absent one targets user_dashboard_preferences_global
-- (the "All Libraries" view). Never pass NULL here — the loose NULL match
-- was removed because it returned an arbitrary library's row.
-- name: GetDashboardPreferences :one
SELECT * FROM user_dashboard_preferences
WHERE user_id = sqlc.narg('user_id') AND (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid);
WHERE user_id = sqlc.narg('user_id') AND library_id = sqlc.narg('library_id')::uuid;
-- name: UpsertDashboardPreferences :one
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
@@ -2456,12 +2478,29 @@ SET hidden_collections = $2,
WHERE user_id = $1 AND library_id = $5
RETURNING *;
-- Global (all-libraries) scope: exactly one row per user.
-- name: GetGlobalDashboardPreferences :one
SELECT user_id, hidden_collections, collection_order, items_per_section, updated_at FROM user_dashboard_preferences_global
WHERE user_id = $1;
-- name: UpsertGlobalDashboardPreferences :one
INSERT INTO user_dashboard_preferences_global (user_id, hidden_collections, collection_order, items_per_section)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id)
DO UPDATE SET
hidden_collections = EXCLUDED.hidden_collections,
collection_order = EXCLUDED.collection_order,
items_per_section = EXCLUDED.items_per_section,
updated_at = NOW()
RETURNING user_id, hidden_collections, collection_order, items_per_section, updated_at;
-- Dashboard collections queries
-- name: GetSystemCollectionsForDashboard :many
SELECT * FROM collections
WHERE user_id = $1
AND is_system_collection = true
AND show_on_dashboard = true
AND deleted_at IS NULL
ORDER BY priority ASC;
-- name: GetUserCollectionsForDashboard :many
@@ -2469,6 +2508,7 @@ SELECT c.* FROM collections c
WHERE c.user_id = $1
AND c.show_on_dashboard = true
AND c.is_system_collection = false
AND c.deleted_at IS NULL
ORDER BY priority ASC;
-- name: DeleteUserSystemCollection :exec
@@ -2477,6 +2517,38 @@ WHERE user_id = $1
AND name = $2
AND is_system_collection = true;
-- System collections are permanent: identity is (user_id, query_type).
-- Restore/reset operate IN PLACE (the row id — and everything referencing
-- it: shelf mappings, prefs order, links — never churns). Deleted sections
-- are TOMBSTONED (deleted_at) and stay invisible until restored.
-- name: GetSystemCollectionByQueryType :one
-- Unfiltered by design: restore/seed must resolve tombstoned rows too.
SELECT * FROM collections
WHERE user_id = $1
AND query_type = $2
AND is_system_collection = true;
-- name: TombstoneSystemCollection :exec
UPDATE collections SET deleted_at = NOW() WHERE id = $1;
-- name: RestoreSystemCollectionRow :one
-- Full reset, in place: canonical metadata, back on the dashboard,
-- un-tombstoned. Item assignments are cleared by the caller ("full").
UPDATE collections
SET name = $2,
description = $3,
icon = $4,
color = $5,
priority = $6,
show_on_dashboard = true,
deleted_at = NULL
WHERE id = $1
RETURNING *;
-- name: ClearCollectionItems :exec
DELETE FROM collection_items WHERE collection_id = $1;
-- name: CreateSystemCollection :one
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
@@ -2564,30 +2636,51 @@ WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('libra
ORDER BY mi.created_at DESC;
-- name: GetDistinctSeries :many
SELECT series, COUNT(*) as book_count,
-- Series are grouped PER LIBRARY: the same series name in two libraries
-- (novels vs manga adaptations, LN splits) stays two entries. Visible
-- filter enforces per-user library visibility at the source.
SELECT library_id, series, COUNT(*) as book_count,
MAX(series_count) as total_in_series,
MAX(created_at) as last_entry_at
FROM media_items
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series IS NOT NULL AND series != ''
GROUP BY series
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
GROUP BY library_id, series
ORDER BY MAX(created_at) DESC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: GetDistinctSeriesCount :one
SELECT COUNT(DISTINCT series)::int
SELECT COUNT(DISTINCT (library_id, series))::int
FROM media_items
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series IS NOT NULL AND series != '';
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != '';
-- name: GetSeriesCovers :many
SELECT cover_image_path, library_id
FROM media_items
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series = sqlc.narg('series') AND cover_image_path IS NOT NULL AND cover_image_path != ''
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series = sqlc.narg('series') AND cover_image_path IS NOT NULL AND cover_image_path != ''
ORDER BY series_number ASC NULLS LAST
LIMIT sqlc.narg('limit');
-- name: GetSeriesBooks :many
-- Scoped to ONE library: same-name series in different libraries are
-- different stories (manga adaptations, LN splits). Callers must pass a
-- library_id and verify the requester can see that library.
SELECT * FROM media_items
WHERE series = sqlc.narg('series')
AND library_id = sqlc.narg('library_id')
AND archived_at IS NULL
AND missing_scan_count = 0
ORDER BY series_number ASC NULLS LAST;
-- name: GetContinueSeriesItems :many
@@ -2728,7 +2821,7 @@ RETURNING *;
-- name: DeleteMediaBookmark :exec
DELETE FROM media_bookmarks
WHERE id = $1;
WHERE id = $1 AND user_id = $2;
-- name: UpdateMediaBookmark :one
UPDATE media_bookmarks
+7
View File
@@ -65,6 +65,7 @@ var SettingDefaults = []SettingDefault{
{Key: "password_require_lower", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one lowercase letter (a-z)"},
{Key: "password_require_number", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one number (0-9)"},
{Key: "password_require_special", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one special character"},
{Key: "registration_enabled", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Public Registration", Description: "Allow users to create their own accounts (admins can always create accounts)"},
{Key: "opds_default_page_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "500", Category: "api", Group: "OPDS Catalog", Description: "Default OPDS page size"},
{Key: "opds_max_page_size", Value: "200", Type: SettingTypeInt, Min: "1", Max: "1000", Category: "api", Group: "OPDS Catalog", Description: "Maximum OPDS page size"},
{Key: "device_rate_sync_per_min", Value: "60", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device sync requests per minute"},
@@ -188,6 +189,12 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration {
// AutoScanEnabled reports whether auto-scanning is on.
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
// RegistrationEnabled reports whether public signup is allowed. Admin user
// creation is never affected — only self-service registration rides this
// flag; handlers additionally grant the first-user exception (see
// handlers/auth.go Register).
func (r *SettingsRegistry) RegistrationEnabled() bool { return r.getBool("registration_enabled") }
// DefaultTimezone returns the configured default timezone name.
func (r *SettingsRegistry) DefaultTimezone() string { return r.raw("default_timezone") }
+24 -13
View File
@@ -2,6 +2,7 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"errors"
"fmt"
@@ -74,22 +75,32 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
startDate := c.QueryParam("start_date")
endDate := c.QueryParam("end_date")
// All range boundaries and day buckets are computed in the REQUESTING
// USER'S timezone: "today" and "which day did this session land on" are
// per-user calendar concepts, not UTC ones.
loc := utils.UserLocation(user.Timezone.String)
now := time.Now().In(loc)
if startDate == "" {
startDate = time.Now().AddDate(0, -1, 0).Format("01-02-2006")
startDate = now.AddDate(0, -1, 0).Format("01-02-2006")
}
if endDate == "" {
endDate = time.Now().Format("01-02-2006")
endDate = now.Format("01-02-2006")
}
startTime, err := time.Parse("01-02-2006", startDate)
startTime, err := time.ParseInLocation("01-02-2006", startDate, loc)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid start_date format")
}
endTime, err := time.Parse("01-02-2006", endDate)
endTime, err := time.ParseInLocation("01-02-2006", endDate, loc)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid end_date format")
}
// The query bounds with <=, so the end bound must be the last instant
// OF the end date — otherwise the end date's own sessions (including
// "today") never appear.
endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond)
ctx := context.Background()
@@ -103,12 +114,12 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get reading history")
}
stats := h.calculateReadingStats(history)
stats := h.calculateReadingStats(history, loc)
return c.JSON(http.StatusOK, stats)
}
func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadingHistoryRow) ReadingStatsResponse {
func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadingHistoryRow, loc *time.Location) ReadingStatsResponse {
stats := ReadingStatsResponse{
DailyReadingMinutes: make([]DailyReading, 0),
}
@@ -130,7 +141,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
longestSession = int(minutes)
}
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
dateKey := entry.CreatedAt.Time.In(loc).Format("01-02-2006")
if dailyMap[dateKey] == nil {
dailyMap[dateKey] = &DailyReading{
Date: dateKey,
@@ -141,7 +152,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
if entry.PagesRead.Valid {
totalPages += int(entry.PagesRead.Int32)
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
dateKey := entry.CreatedAt.Time.In(loc).Format("01-02-2006")
if dailyMap[dateKey] != nil {
dailyMap[dateKey].Pages += int(entry.PagesRead.Int32)
}
@@ -174,7 +185,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
stats.CompletionRate = float64(booksCompleted) / float64(totalBooks)
}
stats.MostActiveDay = h.calculateMostActiveDay(history)
stats.MostActiveDay = h.calculateMostActiveDay(history, loc)
for _, data := range dailyMap {
stats.DailyReadingMinutes = append(stats.DailyReadingMinutes, *data)
@@ -183,13 +194,13 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
return stats
}
func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserReadingHistoryRow) string {
func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserReadingHistoryRow, loc *time.Location) string {
dayCount := make(map[string]int)
days := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
for _, entry := range history {
day := days[entry.CreatedAt.Time.Weekday()]
day := days[entry.CreatedAt.Time.In(loc).Weekday()]
dayCount[day]++
}
@@ -222,7 +233,7 @@ func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
lastSync := ""
if u.LastSync != nil {
if t, ok := u.LastSync.(time.Time); ok {
lastSync = t.Format("01-02-2006 03:04:05 PM")
lastSync = utils.FormatTimeInTimezone(t, user.Timezone.String)
}
}
@@ -274,7 +285,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
lastRead := ""
if book.LastRead != nil {
if t, ok := book.LastRead.(time.Time); ok {
lastRead = t.Format("01-02-2006 03:04:05 PM")
lastRead = utils.FormatTimeInTimezone(t, user.Timezone.String)
}
}
+172 -2
View File
@@ -10,6 +10,7 @@ import (
"context"
"errors"
"fmt"
"html/template"
"net/http"
"os"
"path/filepath"
@@ -38,6 +39,23 @@ var SessionDurationSec = int(DefaultSessionDuration.Seconds())
// SetSettings wires the tunable settings registry (optional).
func (h *AuthHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
// defaultTimezone returns the system-wide default timezone for new users.
// Falls back to UTC when no registry is wired, the setting is empty, or
// the value is not a valid IANA location.
func (h *AuthHandler) defaultTimezone() string {
if h.settings == nil {
return "UTC"
}
tz := h.settings.DefaultTimezone()
if tz == "" {
return "UTC"
}
if _, err := time.LoadLocation(tz); err != nil {
return "UTC"
}
return tz
}
// sessionDuration returns the active session duration from the registry.
func (h *AuthHandler) sessionDuration() time.Duration {
if h.settings != nil {
@@ -101,6 +119,7 @@ type UserProfile struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Role string `json:"role"`
Theme string `json:"theme,omitempty"`
}
type UpdateProfileRequest struct {
@@ -173,6 +192,21 @@ func (h *AuthHandler) Register(c *echo.Context) error {
req.Role = strings.ToLower(req.Role)
}
// Public registration can be disabled by admins. The giveaway exception
// is an unconfigured server: with zero admins the first-user-becomes-
// admin bootstrap path must stay reachable (mirrors setupRedirect-
// Middleware's "users exist" reasoning). Admin account creation via the
// admin API never rides this route, so it is unaffected regardless.
if h.settings != nil && !h.settings.RegistrationEnabled() {
count, countErr := h.db.CountAdmins(c.Request().Context())
if countErr != nil || count > 0 {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusForbidden, `<div class="text-red-500">Registration is disabled on this server</div>`)
}
return c.JSON(http.StatusForbidden, map[string]string{"error": "registration is disabled on this server"})
}
}
if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusConflict, `<div class="text-red-500">Email already exists</div>`)
@@ -246,6 +280,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
Theme: pgtype.Text{String: "tokyo-night", Valid: true},
Role: userRole,
Timezone: pgtype.Text{String: h.defaultTimezone(), Valid: true},
})
if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
@@ -485,7 +520,14 @@ window.location.href = '%s';
// GetProfile handles GET /api/auth/profile
func (h *AuthHandler) GetProfile(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
ctxUser := MustGetAuthenticatedUser(c)
// The context user is a JWT-claims stub (id/email/username/role) —
// it carries no theme. Read the full row: clients (the Android app)
// sync their app-chrome theme from this field.
user, err := h.db.GetUser(c.Request().Context(), ctxUser.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load profile"})
}
firstName := ""
if user.FirstName.Valid {
@@ -502,6 +544,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
FirstName: firstName,
LastName: lastName,
Role: user.Role,
Theme: user.Theme.String,
})
}
@@ -650,6 +693,15 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
}
}
// Browser path (htmx modal + profile page): refresh so every rendered
// value (timezone select, table rows, header) reflects the save and the
// modal closes. A bare JSON body swapped into #profile-result read as
// "nothing happened" — the save DID land, invisibly.
if c.Request().Header.Get("HX-Request") == "true" {
c.Response().Header().Set("HX-Refresh", "true")
return c.HTML(http.StatusOK, `<div class="text-green-500">Profile updated.</div>`)
}
return c.JSON(http.StatusOK, map[string]string{"message": "profile updated successfully"})
}
@@ -662,6 +714,104 @@ func (h *AuthHandler) ListUsers(c *echo.Context) error {
return c.JSON(http.StatusOK, users)
}
// GetPasswordRules handles GET /api/auth/password-rules (public — the
// register page is pre-auth). Exposes the ACTIVE complexity configuration
// so the UI requirement checkers mirror the real rules instead of
// hardcoding defaults. The server remains the enforcer.
func (h *AuthHandler) GetPasswordRules(c *echo.Context) error {
minLength, upper, lower, number, special := 8, true, true, true, true
if h.settings != nil {
r := h.settings.PasswordRules()
minLength, upper, lower, number, special =
r.MinLength, r.Upper, r.Lower, r.Number, r.Special
}
if minLength < 1 {
minLength = 8
}
return c.JSON(http.StatusOK, map[string]interface{}{
"min_length": minLength,
"require_upper": upper,
"require_lower": lower,
"require_number": number,
"require_special": special,
})
}
// AdminCreateUser handles POST /api/auth/users (AdminMiddleware-gated).
// The admin's counterpart to public registration: creating an account for
// someone else must work even when self-service registration is disabled
// (the family/self-hosted default). The requester's admin role comes from
// the route group, and the new account NEVER auto-logins — no tokens are
// issued here, unlike Register.
func (h *AuthHandler) AdminCreateUser(c *echo.Context) error {
var req RegisterRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "username cannot be empty or whitespace"})
}
role := strings.ToLower(req.Role)
if role == "" {
role = "user"
}
if role != "user" && role != "admin" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'user' or 'admin'"})
}
if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil {
return c.JSON(http.StatusConflict, map[string]string{"error": "email already exists"})
}
if _, err := h.db.GetUserByUsername(c.Request().Context(), req.Username); err == nil {
return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"})
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
}
user, err := h.db.CreateUser(c.Request().Context(), database.CreateUserParams{
Email: req.Email,
Username: req.Username,
PasswordHash: string(hashedPassword),
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
Theme: pgtype.Text{String: "tokyo-night", Valid: true},
Role: role,
Timezone: pgtype.Text{String: h.defaultTimezone(), Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create default collections"})
}
// The admin UI posts this via HTMX: refresh the page so the new user
// appears in the table, and confirm inline.
if c.Request().Header.Get("HX-Request") == "true" {
c.Response().Header().Set("HX-Refresh", "true")
return c.HTML(http.StatusCreated, `<div class="text-green-500">User "`+template.HTMLEscapeString(user.Username)+`" created.</div>`)
}
return c.JSON(http.StatusCreated, map[string]interface{}{
"id": uuid.UUID(user.ID.Bytes).String(),
"email": user.Email,
"username": user.Username,
"role": user.Role,
"timezone": user.Timezone,
"created_at": user.CreatedAt,
})
}
// normalizePath cleans and normalizes folder paths for consistent storage and comparison
func normalizePath(path string) string {
fmt.Printf("normalizePath input: '%s'\n", path)
@@ -1067,7 +1217,27 @@ func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userI
}
for _, col := range defaultCollections {
_, err := h.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
// Idempotent by the STABLE identity (user + query_type): a
// preserved or renamed row heals to canonical metadata instead of
// duplicating (the naming-drift bug). Insert only when absent.
existing, err := h.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
UserID: userID,
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
})
if err == nil {
if _, uerr := h.db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
ID: existing.ID,
Name: col.Name,
Description: pgtype.Text{String: col.Description, Valid: true},
Icon: pgtype.Text{String: col.Icon, Valid: true},
Color: pgtype.Text{String: col.Color, Valid: true},
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
}); uerr != nil {
return uerr
}
continue
}
_, err = h.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
UserID: userID,
Name: col.Name,
Description: pgtype.Text{String: col.Description, Valid: true},
+3
View File
@@ -111,6 +111,9 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
req.ViewSettings,
)
if err != nil {
if errors.Is(err, services.ErrCollectionNameTaken) {
return c.JSON(http.StatusConflict, map[string]string{"error": "a collection with this name already exists"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
+3
View File
@@ -406,6 +406,7 @@ func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaIte
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
DeviceSyncData: existing.DeviceSyncData,
UserID: userID,
}
if v, ok := data["selection_text"].(string); ok {
@@ -451,6 +452,7 @@ func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItem
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
DeviceSyncData: existing.DeviceSyncData,
UserID: userID,
}
if v, ok := data["title"].(string); ok {
@@ -486,6 +488,7 @@ func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID p
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
DeviceSyncData: existing.DeviceSyncData,
UserID: userID,
}
if v, ok := data["content"].(string); ok {
+14 -5
View File
@@ -75,7 +75,9 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
LibraryID string `json:"library_id"`
// nil (JSON null/absent) = the global All Libraries scope;
// empty string is a LOST value, not a scope — rejected loudly.
LibraryID *string `json:"library_id"`
HiddenCollections []string `json:"hidden_collections"`
CollectionOrder []string `json:"collection_order"`
ItemsPerSection int `json:"items_per_section"`
@@ -85,9 +87,16 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
libUUID, err := uuid.Parse(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
var libUUID pgtype.UUID
if req.LibraryID != nil {
if *req.LibraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
parsed, err := uuid.Parse(*req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
// Sanitize preferences (remove duplicates)
@@ -97,7 +106,7 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
LibraryID: libUUID,
HiddenCollections: req.HiddenCollections,
CollectionOrder: req.CollectionOrder,
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
+32 -29
View File
@@ -254,9 +254,9 @@ type KoboInitResponse struct {
}
type KoboSyncStatus struct {
Status string `json:"Status"`
MarkupsSynced int `json:"MarkupsSynced"`
BookmarksSynced int `json:"BookmarksSynced"`
Status string `json:"Status"`
MarkupsSynced int `json:"MarkupsSynced"`
BookmarksSynced int `json:"BookmarksSynced"`
DeletedAnnotations []KoboDeletedAnnotation `json:"DeletedAnnotations,omitempty"`
}
@@ -448,16 +448,17 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
if h.progressSvc != nil {
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
CurrentPage: currentPage,
TotalPages: totalPages,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: true,
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
CurrentPage: currentPage,
TotalPages: totalPages,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: true,
ClearLocator: true,
})
} else {
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
@@ -798,14 +799,15 @@ func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error {
if h.progressSvc != nil {
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: true,
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: true,
ClearLocator: true,
})
} else {
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
@@ -860,14 +862,15 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
if h.progressSvc != nil {
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "bookhoard",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: false,
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "bookhoard",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: false,
ClearLocator: true,
})
} else {
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
+41 -19
View File
@@ -93,7 +93,22 @@ func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos
if pos0 == "" || !ec.convertible() {
return "", ""
}
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
// Primary: anchor both endpoints by the selection text itself. One
// document match yields a same-spine ordered pair — the invariant the
// web reader's range-CFI builder needs. Independent per-endpoint
// conversion mis-anchors any selection past a block's first text node
// (crengine text-node indices don't survive to the raw XHTML).
startLoc := wsync.ConvertHighlightRangeToCanonical(ec.epubPath, ec.mediaItem.FormatGroup, pos0, pos1, contextText, percentage)
if startLoc.Precision == "exact" || startLoc.Precision == "structural" {
startCFI := webUsableCFI(startLoc)
endCFI := ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = webUsableCFI(wsync.CanonicalLocator{CFI: startLoc.EndCFI, Precision: startLoc.Precision})
}
return startCFI, endCFI
}
// Fallback: legacy independent per-endpoint conversion.
startLoc = wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
startCFI := webUsableCFI(startLoc)
endCFI := ""
// A text-search start matched the selection text itself: its extent
@@ -219,24 +234,24 @@ type KOReaderProgressRequest struct {
}
type KOReaderBookProgress struct {
UUID string `json:"uuid,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Title string `json:"title,omitempty"`
Authors []string `json:"authors,omitempty"`
Progress float64 `json:"progress"`
Percentage float64 `json:"percentage"`
LastRead string `json:"last_read,omitempty"`
FilePath string `json:"file_path,omitempty"`
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
Notes []KOReaderNote `json:"notes,omitempty"`
Chapter *int `json:"chapter,omitempty"`
Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
ContextText *string `json:"context_text,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
UUID string `json:"uuid,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Title string `json:"title,omitempty"`
Authors []string `json:"authors,omitempty"`
Progress float64 `json:"progress"`
Percentage float64 `json:"percentage"`
LastRead string `json:"last_read,omitempty"`
FilePath string `json:"file_path,omitempty"`
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
Notes []KOReaderNote `json:"notes,omitempty"`
Chapter *int `json:"chapter,omitempty"`
Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
ContextText *string `json:"context_text,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
// Device-side deletions, reported by dedup key. Keys refer to annotations
// the device previously received from the server (or echoes of its own
// pushes); the device only flags a deletion after observing the key in a
@@ -862,6 +877,11 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
if h.progressSvc != nil {
epubcfi := book.Epubcfi
// No position pointer from the device, or a conversion that
// declined below: the write is percentage-only by intent. The
// service then clears any inherited stale anchor instead of
// re-pairing it with the new percentage.
clearLocator := epubcfi == nil
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
// Same facade every annotation uses: cached converter,
// structural-first resolution, guarded fallbacks. The facade
@@ -897,6 +917,7 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
// and the device keeps its own native position.
log.Printf("Bookhoard: CRE→CFI conversion low-confidence (%s) for %s; storing percentage only", loc.Precision, *epubcfi)
epubcfi = nil
clearLocator = true
}
}
}
@@ -916,6 +937,7 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
DeviceType: "koreader",
DeviceName: deviceModel,
Broadcast: true,
ClearLocator: clearLocator,
}
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
+263 -33
View File
@@ -102,13 +102,13 @@ type UpdateMediaItemRequest struct {
// CreateMediaNoteRequest represents the request for creating a media note
type CreateMediaNoteRequest struct {
Content string `json:"content" validate:"required,min=1,max=10000"`
Content string `json:"content" validate:"required,min=1,max=100000"`
Position string `json:"position"`
}
// UpdateMediaNoteRequest represents the request for updating a media note
type UpdateMediaNoteRequest struct {
Content string `json:"content" validate:"required,min=1,max=10000"`
Content string `json:"content" validate:"required,min=1,max=100000"`
Position string `json:"position"`
}
@@ -120,7 +120,7 @@ type CreateMediaHighlightRequest struct {
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
Color string `json:"color" validate:"omitempty,len=7"`
NoteText string `json:"note_text" validate:"max=10000"`
NoteText string `json:"note_text" validate:"max=100000"`
NoteID string `json:"note_id"`
PercentageStart float64 `json:"percentage_start"`
PercentageEnd float64 `json:"percentage_end"`
@@ -135,7 +135,7 @@ type UpdateMediaHighlightRequest struct {
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
Color string `json:"color" validate:"omitempty,len=7"`
NoteText string `json:"note_text" validate:"max=10000"`
NoteText string `json:"note_text" validate:"max=100000"`
NoteID string `json:"note_id"`
PercentageStart float64 `json:"percentage_start"`
PercentageEnd float64 `json:"percentage_end"`
@@ -146,7 +146,7 @@ type UpdateMediaHighlightRequest struct {
type CreateMediaBookmarkRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Position string `json:"position" validate:"max=100"`
Notes string `json:"notes" validate:"max=10000"`
Notes string `json:"notes" validate:"max=100000"`
CfiPosition string `json:"cfi_position" validate:"max=255"`
PageNumber int32 `json:"page_number"`
ChapterNumber int32 `json:"chapter_number"`
@@ -160,7 +160,7 @@ type CreateMediaBookmarkRequest struct {
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark
type UpdateMediaBookmarkRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Notes string `json:"notes" validate:"max=10000"`
Notes string `json:"notes" validate:"max=100000"`
Position string `json:"position" validate:"max=100"`
}
@@ -439,6 +439,20 @@ func (h *MediaHandler) HandleBulkDelete(c *echo.Context) error {
continue
}
// Storage-down guard: deleting cascades the item's annotations, so
// refuse while the library storage is unreachable — the file would
// survive as an orphan and re-import bare on the next scan.
storage := h.resolveMediaStorage(c.Request().Context(), uuid.UUID(media.LibraryID.Bytes).String(), media.FilePath)
if !storage.Reachable {
results = append(results, map[string]interface{}{
"media_item_id": mediaIDStr,
"status": "error",
"error": "storage unreachable — archive the item instead, or retry when the storage is mounted",
})
failedCount++
continue
}
err = h.db.DeleteMediaItem(c.Request().Context(), mediaUUID)
if err != nil {
results = append(results, map[string]interface{}{
@@ -450,11 +464,11 @@ func (h *MediaHandler) HandleBulkDelete(c *echo.Context) error {
continue
}
if media.FilePath != "" {
if err := os.Remove(media.FilePath); err != nil {
if storage.AbsolutePath != "" {
if err := os.Remove(storage.AbsolutePath); err != nil {
// Log file removal failure but don't fail the request
// DB record is already deleted, which is the primary concern
log.Printf("Warning: failed to remove file %s: %v", media.FilePath, err)
log.Printf("Warning: failed to remove file %s: %v", storage.AbsolutePath, err)
}
}
@@ -626,6 +640,77 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
})
}
// visibleLibrarySet loads the requester's visible libraries — membership
// map for param checks, id slice for SQL ANY() filters.
func (mh *MediaHandler) visibleLibrarySet(c *echo.Context) (map[string]bool, []pgtype.UUID, error) {
user, ok := c.Get("user").(database.Users)
if !ok {
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
libraries, err := mh.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err != nil {
return nil, nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to check library access")
}
set := make(map[string]bool, len(libraries))
ids := make([]pgtype.UUID, 0, len(libraries))
for _, lib := range libraries {
if !lib.ID.Valid {
continue
}
set[uuid.UUID(lib.ID.Bytes).String()] = true
ids = append(ids, lib.ID)
}
return set, ids, nil
}
// mediaStorageState reports whether the library storage holding a media
// item's file is currently reachable, and resolves the file's absolute
// path when it can be located.
//
// Why this exists: deleting a media item cascades its annotations, so the
// delete must never proceed while the storage is unreachable — otherwise
// a NAS outage turns an admin cleanup into permanent annotation loss
// (the DB row dies, the orphaned file re-imports bare on the next scan).
type mediaStorageState struct {
// Absolute path of the file when located on reachable storage;
// empty when the file is missing or reachability is undeterminable.
AbsolutePath string
// Reachable is false ONLY when the library has folders and every one
// of them fails to stat — positive evidence the mount is down. When
// no folders are configured reachability is undeterminable and this
// is true (delete proceeds; behavior unchanged from before this check).
Reachable bool
}
func (mh *MediaHandler) resolveMediaStorage(ctx context.Context, libraryID string, relativePath string) mediaStorageState {
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return mediaStorageState{Reachable: true}
}
folders, err := mh.libraryService.GetLibraryFolders(ctx, pgtype.UUID{Bytes: libUUID, Valid: true})
if err != nil || len(folders) == 0 {
return mediaStorageState{Reachable: true}
}
anyFolderUp := false
for _, f := range folders {
if _, err := os.Stat(f.FolderPath); err != nil {
continue // folder root unreachable
}
anyFolderUp = true
candidate := filepath.Join(f.FolderPath, filepath.FromSlash(relativePath))
if _, err := os.Stat(candidate); err == nil {
return mediaStorageState{AbsolutePath: candidate, Reachable: true}
}
}
if !anyFolderUp {
return mediaStorageState{Reachable: false}
}
// Storage is up but the file is genuinely gone: a stale row is safe
// to delete.
return mediaStorageState{Reachable: true}
}
// ListMediaItems handles GET /api/media-items
func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
libraryID := c.QueryParam("library_id")
@@ -678,6 +763,14 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
}
visibleSet, _, err := mh.visibleLibrarySet(c)
if err != nil {
return err
}
if !visibleSet[libraryID] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
items, err := mh.db.ListMediaItemsSorted(c.Request().Context(), database.ListMediaItemsSortedParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Sort: pgtype.Text{String: sort, Valid: true},
@@ -691,9 +784,14 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{"data": items})
}
_, visibleIDs, err := mh.visibleLibrarySet(c)
if err != nil {
return err
}
items, err := mh.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
Limit: int32(limit),
Offset: int32(offset),
Limit: int32(limit),
Offset: int32(offset),
VisibleLibraryIds: visibleIDs,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -764,6 +862,15 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Visibility gate: hidden libraries don't leak row detail by id.
visibleSet, _, err := mh.visibleLibrarySet(c)
if err != nil {
return err
}
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"id": uuid.UUID(item.ID.Bytes).String(),
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
@@ -953,6 +1060,37 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
"last_sync_timestamp": progress.LastSyncTimestamp,
}
// Position authority: re-verify the stored anchor and derive the
// client-facing handles (cssSelector + anchor document href) server-
// side, so clients scroll to an address instead of parsing CFIs.
if progress.Epubcfi.Valid && wsync.IsConvertibleFormat(progress.FormatGroup) && mh.libraryService != nil {
contextText := ""
if progress.ContextText.Valid {
contextText = progress.ContextText.String
}
pct := 0.0
if progress.Percentage.Valid {
pct = progress.Percentage.Float64
}
if mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err == nil {
if path, perr := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath); perr == nil && path != "" {
if finalCFI, sel, href, charOff, _, _, _, verr := wsync.VerifyProgressAnchor(path, progress.Epubcfi.String, contextText, pct); verr == nil && sel != "" {
resp["css_selector"] = sel
resp["anchor_href"] = href
if charOff != nil {
resp["char_offset"] = *charOff
}
if finalCFI != "" {
resp["epubcfi"] = finalCFI
}
}
}
}
}
if progress.ContextText.Valid {
resp["context_text"] = progress.ContextText.String
}
return c.JSON(http.StatusOK, resp)
}
@@ -1163,7 +1301,10 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
})
}
mediaItemID, err := mh.db.CreateMediaItem(c.Request().Context(), database.CreateMediaItemParams{
// Race-safe create-or-detect: on a duplicate (library_id, file_path)
// the insert writes nothing and returns no row — answer with the
// EXISTING item, a duplicate flag, and 200 OK (not a 201 lie).
created, err := mh.db.CreateMediaItemIfNew(c.Request().Context(), database.CreateMediaItemIfNewParams{
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
@@ -1184,11 +1325,25 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
ContributorsSearch: contributorsSearch,
AddedByAdminID: user.ID,
})
if errors.Is(err, pgx.ErrNoRows) {
existing, gerr := mh.db.GetMediaItemByLibraryAndPath(c.Request().Context(), database.GetMediaItemByLibraryAndPathParams{
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
FilePath: req.FilePath,
})
if gerr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": gerr.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"duplicate": true,
"message": "A media item already exists for this file_path in this library",
"item": existing,
})
}
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaItemID.ID.Bytes, Valid: true})
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: created.ID.Bytes, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
@@ -1506,11 +1661,35 @@ func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
media, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Storage-down guard (see HandleBulkDelete): refuse while the library
// storage is unreachable so the cascade can't destroy annotations for
// a file that will re-import on the next scan.
storage := mh.resolveMediaStorage(c.Request().Context(), uuid.UUID(media.LibraryID.Bytes).String(), media.FilePath)
if !storage.Reachable {
return c.JSON(http.StatusConflict, map[string]string{
"error": "storage unreachable — archive the item instead, or retry when the storage is mounted",
})
}
err = mh.db.DeleteMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if storage.AbsolutePath != "" {
if err := os.Remove(storage.AbsolutePath); err != nil {
log.Printf("Warning: failed to remove file %s: %v", storage.AbsolutePath, err)
}
}
return c.NoContent(http.StatusNoContent)
}
@@ -1618,6 +1797,12 @@ func (mh *MediaHandler) UpdateMediaNote(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
}
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
var req UpdateMediaNoteRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
@@ -1630,6 +1815,7 @@ func (mh *MediaHandler) UpdateMediaNote(c *echo.Context) error {
ID: pgtype.UUID{Bytes: noteUUID, Valid: true},
Content: req.Content,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -1646,10 +1832,19 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
}
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
if mh.annotationSvc != nil {
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}, pgtype.UUID{Bytes: userUUID, Valid: true})
} else {
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
err = mh.db.DeleteMediaNote(c.Request().Context(), database.DeleteMediaNoteParams{
ID: pgtype.UUID{Bytes: noteUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
}
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -1851,6 +2046,12 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
return c.JSON(http.StatusOK, result.Highlight)
}
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
highlight, err := mh.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{
ID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
SelectionText: req.SelectionText,
@@ -1858,6 +2059,7 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
Color: pgtype.Text{String: color, Valid: true},
NoteID: noteUUID,
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -1876,14 +2078,27 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true}
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
if mh.annotationSvc != nil {
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil {
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgUserID, pgHighlightID, "web"); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID)
err = mh.db.DeleteMediaHighlight(c.Request().Context(), database.DeleteMediaHighlightParams{
ID: pgHighlightID,
UserID: pgUserID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
@@ -2028,14 +2243,27 @@ func (mh *MediaHandler) DeleteMediaBookmark(c *echo.Context) error {
pgBookmarkID := pgtype.UUID{Bytes: bookmarkUUID, Valid: true}
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
if mh.annotationSvc != nil {
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgBookmarkID, "web"); err != nil {
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgUserID, pgBookmarkID, "web"); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "bookmark not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
if err := mh.db.DeleteMediaBookmark(c.Request().Context(), pgBookmarkID); err != nil {
if err := mh.db.DeleteMediaBookmark(c.Request().Context(), database.DeleteMediaBookmarkParams{
ID: pgBookmarkID,
UserID: pgUserID,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
@@ -2102,6 +2330,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
authorFilter := c.QueryParam("author_filter")
seriesFilter := c.QueryParam("series_filter")
genreFilter := c.QueryParam("genre_filter")
publisherFilter := c.QueryParam("publisher_filter")
tagsFilter := c.QueryParam("tags_filter")
languageFilter := c.QueryParam("language_filter")
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
@@ -2121,20 +2350,21 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
// Build search params
params := services.SearchParams{
UserID: userID.ID,
LibraryID: libUUID,
SearchQuery: query,
AuthorFilter: authorFilter,
SeriesFilter: seriesFilter,
GenreFilter: genreFilter,
TagsFilter: tagsFilter,
LanguageFilter: languageFilter,
YearMin: yearMin,
YearMax: yearMax,
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
Sort: sortParam,
Limit: limit,
Offset: offset,
UserID: userID.ID,
LibraryID: libUUID,
SearchQuery: query,
AuthorFilter: authorFilter,
SeriesFilter: seriesFilter,
GenreFilter: genreFilter,
PublisherFilter: publisherFilter,
TagsFilter: tagsFilter,
LanguageFilter: languageFilter,
YearMin: yearMin,
YearMax: yearMax,
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
Sort: sortParam,
Limit: limit,
Offset: offset,
}
c.Logger().Info("SearchMediaItems called",
+2 -2
View File
@@ -402,7 +402,7 @@ func (h *OPDSHandler) addAcquisitionEntries(ctx context.Context, feed *opds.Feed
author = item.Author.String
}
updated := item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z")
updated := item.UpdatedAt.Time.UTC().Format("2006-01-02T15:04:05Z")
entry := opds.NewEntry(
fmt.Sprintf("urn:uuid:%s", bookUUID),
@@ -706,7 +706,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
author = item.Author.String
}
updated := item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z")
updated := item.UpdatedAt.Time.UTC().Format("2006-01-02T15:04:05Z")
entry := opds.NewEntry(
fmt.Sprintf("urn:uuid:%s", bookUUID),
+2 -117
View File
@@ -97,121 +97,6 @@ func (h *Handler) GetUniversalProgress(c *echo.Context) error {
}
// UpdateUniversalProgress updates progress with automatic conversion
func (h *Handler) UpdateUniversalProgress(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
mediaItemID, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid media item ID")
}
var req struct {
Source string `json:"source"`
Location struct {
Percentage *float64 `json:"percentage"`
Page *int `json:"page"`
TotalPages *int `json:"total_pages"`
Epubcfi *string `json:"epubcfi"`
Character *int64 `json:"character"`
Chapter *int `json:"chapter"`
ViewportY *float64 `json:"viewport_y"`
} `json:"location"`
DeviceMetadata struct {
DeviceType string `json:"device_type"`
UserAgent string `json:"user_agent"`
} `json:"device_metadata"`
}
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
mediaItem, err := h.db.GetMediaItem(context.Background(), pgtype.UUID{Bytes: mediaItemID, Valid: true})
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "media item not found")
}
percentage := 0.0
if req.Location.Percentage != nil {
percentage = *req.Location.Percentage
} else if req.Location.Page != nil && req.Location.TotalPages != nil {
percentage = wsync.PageToPercentage(*req.Location.Page, *req.Location.TotalPages)
}
currentPage := 0
totalPages := 0
if req.Location.Page != nil {
currentPage = *req.Location.Page
}
if req.Location.TotalPages != nil {
totalPages = *req.Location.TotalPages
}
if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
totalPages = int(mediaItem.PageCount.Int32)
}
var epubcfi pgtype.Text
if req.Location.Epubcfi != nil {
epubcfi = pgtype.Text{String: *req.Location.Epubcfi, Valid: true}
}
var chapter pgtype.Int4
if req.Location.Chapter != nil {
chapter = pgtype.Int4{Int32: int32(*req.Location.Chapter), Valid: true}
}
var viewportY pgtype.Float8
if req.Location.ViewportY != nil {
viewportY = pgtype.Float8{Float64: *req.Location.ViewportY, Valid: true}
}
var lastSyncDevice, lastSyncSource pgtype.Text
if req.DeviceMetadata.DeviceType != "" {
lastSyncDevice = pgtype.Text{String: req.DeviceMetadata.DeviceType, Valid: true}
lastSyncSource = pgtype.Text{String: req.Source, Valid: true}
}
updated, err := h.db.UpdateUniversalProgress(context.Background(), database.UpdateUniversalProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
Epubcfi: epubcfi,
Chapter: chapter,
ChapterProgress: pgtype.Float8{Float64: percentage, Valid: true},
ViewportY: viewportY,
LastSyncDevice: lastSyncDevice,
LastSyncSource: lastSyncSource,
CurrentPage: pgtype.Int4{Int32: int32(currentPage), Valid: true},
TotalPages: pgtype.Int4{Int32: int32(totalPages), Valid: true},
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to update progress")
}
// Broadcast progress update to all connected WebSocket clients
deviceName := "Web"
deviceType := "web"
if req.DeviceMetadata.DeviceType != "" {
deviceType = req.DeviceMetadata.DeviceType
deviceName = req.DeviceMetadata.DeviceType
}
h.connManager.BroadcastProgressUpdate(mediaItemID, percentage, wsync.SourceDevice{
ID: uuid.UUID(user.ID.Bytes).String(),
Name: deviceName,
Type: deviceType,
})
return c.JSON(http.StatusOK, map[string]interface{}{
"sync_status": "success",
"progress_updated": true,
"percentage": percentage,
"current_page": currentPage,
"total_pages": totalPages,
"last_read_at": updated.LastReadAt.Time,
})
}
// GetProgressHistory retrieves reading session history
func (h *Handler) GetProgressHistory(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
@@ -306,7 +191,7 @@ func (h *Handler) GetAllProgress(c *echo.Context) error {
lastUpdated := ""
if progress.LastReadAt.Valid {
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
lastUpdated = utils.FormatTimeInTimezone(progress.LastReadAt.Time, user.Timezone.String)
}
progressList = append(progressList, ProgressWithMedia{
@@ -376,7 +261,7 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
lastUpdated := ""
if progress.LastReadAt.Valid {
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
lastUpdated = utils.FormatTimeInTimezone(progress.LastReadAt.Time, user.Timezone.String)
}
progressList = append(progressList, ProgressWithMedia{
+12 -3
View File
@@ -2,6 +2,7 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"net/http"
"strconv"
@@ -47,7 +48,11 @@ type QueueItemResponse struct {
Status string `json:"status"`
ErrorMessage *string `json:"error_message,omitempty"`
CreatedAt string `json:"created_at"`
ProcessedAt *string `json:"processed_at,omitempty"`
// CreatedAtDisplay is the server-rendered wall-clock string in the
// requesting admin's timezone (templates render this; created_at stays
// machine-readable RFC3339 for API consumers).
CreatedAtDisplay string `json:"created_at_display,omitempty"`
ProcessedAt *string `json:"processed_at,omitempty"`
}
func (h *QueueHandler) GetDeviceQueueStats(c *echo.Context) error {
@@ -136,7 +141,9 @@ func (h *QueueHandler) ListDeviceQueueItems(c *echo.Context) error {
Status: item.Status.String,
ErrorMessage: textPtrToString(item.ErrorMessage),
CreatedAt: item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
CreatedAtDisplay: utils.FormatTimeInTimezone(
item.CreatedAt.Time, user.Timezone.String),
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
})
}
@@ -189,7 +196,9 @@ func (h *QueueHandler) GetQueueData(c *echo.Context) ([]QueueItemResponse, error
Status: item.Status.String,
ErrorMessage: textPtrToString(item.ErrorMessage),
CreatedAt: item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
CreatedAtDisplay: utils.FormatTimeInTimezone(
item.CreatedAt.Time, user.Timezone.String),
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
})
}
+43 -51
View File
@@ -213,6 +213,27 @@ func (h *ReaderHandler) GetPage(c *echo.Context) error {
}
// GetChapters returns chapter metadata
// ensureItemVisible enforces the library-visibility gate for reader
// content and layout endpoints (same rule GetPage applies): a hidden
// library leaks nothing by item id.
func (h *ReaderHandler) ensureItemVisible(c *echo.Context, mediaItem *database.MediaItems) error {
user := c.Get("user")
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "User not authenticated"})
}
userData := user.(database.Users)
libraries, err := h.libraryService.GetUserVisibleLibraries(c.Request().Context(), userData.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to check library access"})
}
for _, lib := range libraries {
if lib.ID == mediaItem.LibraryID {
return nil
}
}
return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied to this library"})
}
func (h *ReaderHandler) GetChapters(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
parsedUUID, err := uuid.Parse(mediaItemID)
@@ -220,6 +241,14 @@ func (h *ReaderHandler) GetChapters(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
}
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
}
if err := h.ensureItemVisible(c, &mediaItem); err != nil {
return err
}
// Use reader service to detect chapters
chapters, err := h.readerService.DetectChapters(c.Request().Context(), parsedUUID)
if err != nil {
@@ -246,6 +275,14 @@ func (h *ReaderHandler) GetPanels(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
}
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
}
if err := h.ensureItemVisible(c, &mediaItem); err != nil {
return err
}
// Get detection method from query
method := c.QueryParam("method")
if method == "" {
@@ -398,6 +435,9 @@ func (h *ReaderHandler) GetPDFOutline(c *echo.Context) error {
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
}
if err := h.ensureItemVisible(c, &mediaItem); err != nil {
return err
}
// Only PDFs have outlines
if mediaItem.FormatGroup != "pdf" {
@@ -438,6 +478,9 @@ func (h *ReaderHandler) GetPDFThumbnail(c *echo.Context) error {
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
}
if err := h.ensureItemVisible(c, &mediaItem); err != nil {
return err
}
// Get thumbnail size from query
width := c.QueryParam("width")
@@ -590,57 +633,6 @@ func (h *ReaderHandler) generatePDFThumbnail(fullPath string, page int, width, h
return []byte{}, nil
}
func (h *ReaderHandler) ParseEbook(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return c.JSON(400, map[string]string{"error": "Invalid media item ID"})
}
// Fetch media item
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(404, map[string]string{"error": "Media item not found"})
}
return c.JSON(500, map[string]string{"error": "Failed to fetch media item"})
}
// Check if format requires server-side parsing
requiresServerParsing := false
formatName := ""
switch mediaItem.MimeType.String {
case "application/x-mobipocket-ebook":
requiresServerParsing = true
formatName = "MOBI"
case "application/vnd.amazon.mobi8-ebook":
requiresServerParsing = true
formatName = "AZW3/KF8"
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
requiresServerParsing = true
formatName = "DOCX"
case "application/rtf":
requiresServerParsing = true
formatName = "RTF"
}
if requiresServerParsing {
// Phase 1: Return 501 for server-side formats
return c.JSON(501, map[string]interface{}{
"error": fmt.Sprintf("Server-side %s parsing is not yet implemented", formatName),
"message": fmt.Sprintf("%s format support is planned for Phase 2.5", formatName),
"suggestion": "Please convert your ebook to EPUB format for now",
"format": formatName,
"phase": "2.5",
})
}
// Client-side format - should not call this endpoint
return c.JSON(400, map[string]string{
"error": "This format should be parsed client-side, not on the server",
})
}
// ShouldTreatAsComic reports whether a fixed-layout item that lives in a
// comics or manga library should get comic reader treatment: bookmarks
// only — no text-selection highlights, no annotations, no in-book search.
+66 -7
View File
@@ -50,13 +50,25 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
}
}
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, limit, offset)
// Visibility scoping: an explicit library_id must be visible to the
// requester (403 otherwise); the unscoped aggregate covers only the
// requester's visible libraries.
visibleSet, visibleIDs, err := h.visibleLibrarySet(c)
if err != nil {
return err
}
if libraryID != "" && !visibleSet[libraryID] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
}
type SeriesResponse struct {
Name string `json:"name"`
LibraryID string `json:"library_id"`
BookCount int64 `json:"book_count"`
TotalInSeries int `json:"total_in_series"`
CoverPaths []string `json:"cover_paths"`
@@ -67,6 +79,7 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
for _, s := range seriesList {
response = append(response, SeriesResponse{
Name: s.Name,
LibraryID: s.LibraryID,
BookCount: s.BookCount,
TotalInSeries: s.TotalInSeries,
CoverPaths: s.CoverPaths,
@@ -82,13 +95,58 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
})
}
// visibleLibrarySet loads the requester's visible libraries. Every series
// path funnels through this: hidden libraries contribute nothing, and an
// explicit library_id outside the set is a 403, not a silent pass.
func (h *SeriesHandler) visibleLibrarySet(c *echo.Context) (map[string]bool, []pgtype.UUID, error) {
user, ok := c.Get("user").(database.Users)
if !ok {
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
libraries, err := h.seriesService.VisibleLibraries(c.Request().Context(), user.ID)
if err != nil {
return nil, nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to check library access")
}
set := make(map[string]bool, len(libraries))
ids := make([]pgtype.UUID, 0, len(libraries))
for _, lib := range libraries {
if !lib.ID.Valid {
continue
}
libUUID, err := uuid.FromBytes(lib.ID.Bytes[0:16])
if err != nil {
continue
}
set[libUUID.String()] = true
ids = append(ids, lib.ID)
}
return set, ids, nil
}
func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
seriesName := c.QueryParam("name")
if seriesName == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"})
}
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), seriesName)
libraryID := c.QueryParam("library_id")
if libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
visibleSet, _, err := h.visibleLibrarySet(c)
if err != nil {
return err
}
if !visibleSet[libraryID] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true}, seriesName)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series books"})
}
@@ -105,13 +163,14 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
}
return c.JSON(http.StatusOK, map[string]interface{}{
"name": seriesName,
"books": bookCards,
"total": len(bookCards),
"name": seriesName,
"library_id": libraryID,
"books": bookCards,
"total": len(bookCards),
})
}
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
svc := services.NewSeriesService(db)
return svc.GetSeriesPage(ctx, libraryID, limit, offset)
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset)
}
+2
View File
@@ -12,6 +12,7 @@ func registerAuthRoutes(cfg *Config, rateLimitMiddleware echo.MiddlewareFunc) {
// Auth routes (no auth required, but rate limited)
e.POST("/api/auth/register", rateLimitMiddleware(cfg.AuthHandler.Register))
e.POST("/api/auth/login", rateLimitMiddleware(cfg.AuthHandler.Login))
e.GET("/api/auth/password-rules", cfg.AuthHandler.GetPasswordRules)
// JWT middleware for protected routes
jwtMiddleware := createJWTMiddleware(cfg)
@@ -40,6 +41,7 @@ func registerAuthRoutes(cfg *Config, rateLimitMiddleware echo.MiddlewareFunc) {
// Admin-only routes (same handlers with URL param)
admin := protected.Group("/auth", handlers.AdminMiddleware)
admin.GET("/users", cfg.AuthHandler.ListUsers)
admin.POST("/users", cfg.AuthHandler.AdminCreateUser)
admin.PUT("/users/:id/max-devices", cfg.AuthHandler.UpdateUserMaxDevices)
admin.PUT("/profile/:id", cfg.AuthHandler.UpdateProfile)
admin.PUT("/password/:id", cfg.AuthHandler.UpdatePassword)
+66 -24
View File
@@ -62,7 +62,7 @@ func registerFrontendRoutes(cfg *Config) {
var buf bytes.Buffer
sessionExpired := c.QueryParam("session") == "expired"
deleted := c.QueryParam("deleted") == "true"
err := templates.Login(sessionExpired, deleted).Render(c.Request().Context(), &buf)
err := templates.Login(sessionExpired, deleted, templates.SignupVisible()).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -70,6 +70,13 @@ func registerFrontendRoutes(cfg *Config) {
})
e.GET("/register", func(c *echo.Context) error {
// Registration can be disabled by admins. The first-user exception
// keeps the page reachable on an unconfigured server (zero admins) —
// see templates.SignupVisible for the shared reasoning. Redirect
// (not 404) so stale links and bookmarks land somewhere sensible.
if !templates.SignupVisible() {
return c.Redirect(http.StatusFound, "/login")
}
var buf bytes.Buffer
err := templates.Register().Render(c.Request().Context(), &buf)
if err != nil {
@@ -101,7 +108,7 @@ func registerFrontendRoutes(cfg *Config) {
loggedIn = err == nil && token.Valid
}
err = templates.Index(loggedIn).Render(c.Request().Context(), &buf)
err = templates.Index(loggedIn, templates.SignupVisible()).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -113,7 +120,7 @@ func registerFrontendRoutes(cfg *Config) {
// ============================================================================
// Protected frontend routes (no /api prefix)
frontendProtected := e.Group("", jwtMiddleware, ensureUserExistsMiddleware(cfg))
frontendProtected := e.Group("", jwtMiddleware)
// Series browse page
frontendProtected.GET("/series", func(c *echo.Context) error {
@@ -144,27 +151,38 @@ func registerFrontendRoutes(cfg *Config) {
totalPages := 1
if errorMsg == "" {
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, perSeriesPage, offset)
if err != nil {
log.Printf("GetSeriesCardsData failed: %v", err)
// Visible-library scoping: cards are grouped per library, and a
// specific library selection only renders if it's in the set.
visibleSet, visibleIDs, visErr := visibleLibraryIDsFor(c, cfg)
if visErr != nil {
log.Printf("visibleLibraryIDsFor failed: %v", visErr)
errorMsg = "Error loading series"
} else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] {
errorMsg = "Library not accessible"
} else {
totalPages = (total + perSeriesPage - 1) / perSeriesPage
if totalPages < 1 {
totalPages = 1
}
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
for _, s := range seriesList {
covers := s.CoverPaths
if covers == nil {
covers = []string{}
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset)
if err != nil {
log.Printf("GetSeriesCardsData failed: %v", err)
errorMsg = "Error loading series"
} else {
totalPages = (total + perSeriesPage - 1) / perSeriesPage
if totalPages < 1 {
totalPages = 1
}
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
for _, s := range seriesList {
covers := s.CoverPaths
if covers == nil {
covers = []string{}
}
seriesCards = append(seriesCards, templates.SeriesCardData{
Name: s.Name,
LibraryID: s.LibraryID,
BookCount: s.BookCount,
TotalInSeries: s.TotalInSeries,
CoverPaths: covers,
})
}
seriesCards = append(seriesCards, templates.SeriesCardData{
Name: s.Name,
BookCount: s.BookCount,
TotalInSeries: s.TotalInSeries,
CoverPaths: covers,
})
}
}
}
@@ -195,10 +213,28 @@ func registerFrontendRoutes(cfg *Config) {
return renderErrorPage(c, "Series name required", "bad_request")
}
// Series are per-library now: the detail page must say WHICH library's
// series it is showing, and the requester must be able to see it.
libraryIDParam := c.QueryParam("library_id")
if libraryIDParam == "" {
return renderErrorPage(c, "Library required", "bad_request")
}
libUUIDParam, err := uuid.Parse(libraryIDParam)
if err != nil {
return renderErrorPage(c, "Invalid library", "bad_request")
}
visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
if visErr != nil {
return renderErrorPage(c, "Error checking access", "access_error")
}
if !visibleSet[libraryIDParam] {
return renderErrorPage(c, "Access denied", "access_denied")
}
var bookInfoList []handlers.BookInfo
svc := services.NewSeriesService(cfg.Queries)
books, err := svc.GetSeriesBooks(c.Request().Context(), seriesName)
books, err := svc.GetSeriesBooks(c.Request().Context(), pgtype.UUID{Bytes: libUUIDParam, Valid: true}, seriesName)
if err != nil {
log.Printf("GetSeriesBooks failed: %v", err)
errorMsg = "Error loading series books"
@@ -1035,7 +1071,7 @@ func registerFrontendRoutes(cfg *Config) {
LibraryName: p.LibraryName,
SHA256: p.FileSha256,
SHAShort: p.FileSha256[:16] + "…",
CreatedAt: p.CreatedAt.Time.Format("Jan 2, 2006"),
CreatedAt: p.CreatedAt.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006"),
Items: []templates.HashConflictItemData{},
}
@@ -1218,6 +1254,9 @@ func registerFrontendRoutes(cfg *Config) {
FirstName: getText(targetUser.FirstName),
LastName: getText(targetUser.LastName),
CreatedAt: targetUser.CreatedAt.Time,
// Without this the timezone select rendered UTC selected for
// EVERY user — saves landed, the modal just never showed them.
Timezone: getText(targetUser.Timezone),
}
// Render modal
@@ -1429,7 +1468,10 @@ func registerFrontendRoutes(cfg *Config) {
// Render template
var buf bytes.Buffer
err = templates.BookDetail(user, detail, "").Render(c.Request().Context(), &buf)
// The deleted-annotations modal states the retention in plain days
// (the tunable's user-facing value), never as a settings name.
tombstoneDays := cfg.Settings.TombstoneTTL() / (24 * time.Hour)
err = templates.BookDetail(user, detail, "", int(tombstoneDays)).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
+88 -8
View File
@@ -3,8 +3,10 @@ package router
import (
"context"
"log"
"net/http"
"net/url"
"os"
"sync"
"time"
"bookhoard/internal/database"
@@ -51,13 +53,16 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
}
return templates.User{
ID: userID,
Email: userEmail,
Username: userUsername,
Role: userRole,
Theme: userTheme,
Token: token,
Timezone: userTimezone,
ID: userID,
Email: userEmail,
Username: userUsername,
Role: userRole,
Theme: userTheme,
Token: token,
Timezone: userTimezone,
FirstName: getText(userDB.FirstName),
LastName: getText(userDB.LastName),
CreatedAt: userDB.CreatedAt.Time,
}, nil
}
@@ -102,6 +107,34 @@ type LibraryResolution struct {
FirstID string
}
// folderStatEntry caches one folder-reachability result.
type folderStatEntry struct {
exists bool
at time.Time
}
const folderStatTTL = 30 * time.Second
var folderStatCache sync.Map
// folderReachableCached stats a library folder with a short TTL cache:
// resolveLibrary runs on every SSR render, and a stat per library folder
// over a network mount adds real latency to each page. The cache trades
// ~30s of stale online/offline state for snappy renders.
func folderReachableCached(path string) bool {
if v, ok := folderStatCache.Load(path); ok {
if e, ok := v.(folderStatEntry); ok && time.Since(e.at) < folderStatTTL {
return e.exists
}
}
exists := false
if _, err := os.Stat(path); err == nil {
exists = true
}
folderStatCache.Store(path, folderStatEntry{exists: exists, at: time.Now()})
return exists
}
func getText(t pgtype.Text) string {
if t.Valid {
return t.String
@@ -118,6 +151,15 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
log.Printf("GetUserVisibleLibraries failed: %v", err)
libraries = []database.GetUserVisibleLibrariesRow{}
}
visibleSet := make(map[string]bool, len(libraries))
for _, lib := range libraries {
if !lib.ID.Valid {
continue
}
if id, err := uuid.FromBytes(lib.ID.Bytes[0:16]); err == nil {
visibleSet[id.String()] = true
}
}
counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU))
if countErr != nil {
@@ -143,7 +185,7 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
} else {
anyFolderExists := false
for _, folder := range folders {
if _, statErr := os.Stat(folder.FolderPath); statErr == nil {
if folderReachableCached(folder.FolderPath) {
anyFolderExists = true
break
}
@@ -189,8 +231,46 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
return res
}
// A requested library outside the requester's visible set is not
// honored — fall back to the first visible library instead of leaking
// hidden-library contents through a hand-typed query param or cookie.
if !visibleSet[libraryID] {
res.LibraryID = res.FirstID
if res.LibraryID != "" {
parsed, _ := uuid.Parse(res.LibraryID)
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
return res
}
res.LibraryID = libraryID
parsed, _ := uuid.Parse(libraryID)
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
return res
}
// visibleLibraryIDsFor returns the requester's visible-library id set (for
// membership checks) plus the ids themselves (for SQL ANY() filters).
func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgtype.UUID, error) { userUUIDStr, ok := c.Get("user_id").(string)
if !ok {
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
userU, err := uuid.Parse(userUUIDStr)
if err != nil {
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userU))
if err != nil {
return nil, nil, err
}
set := make(map[string]bool, len(libraries))
ids := make([]pgtype.UUID, 0, len(libraries))
for _, lib := range libraries {
if !lib.ID.Valid {
continue
}
set[lib.ID.String()] = true
ids = append(ids, lib.ID)
}
return set, ids, nil
}
+58 -37
View File
@@ -69,7 +69,7 @@ type Config struct {
LoginTracker *ratelimit.LoginAttemptTracker
ScannerHandler *handlers.Handler
JobsHandler *handlers.JobsHandler
SidecarHandler *handlers.SidecarHandler
SidecarHandler *handlers.SidecarHandler
ReaderHandler *handlers.ReaderHandler
LibraryService *services.LibraryService
}
@@ -113,11 +113,41 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
return echo.NewHTTPError(http.StatusBadRequest, "invalid user ID in token")
}
// Ghost-session guard: a signature-valid token for a user that
// no longer exists must not authenticate (deleted users kept
// full access until token expiry before this check). A
// primary-key lookup per request — instant revocation, no TTL
// semantics; the guard also covers the SSR groups that used to
// chain ensureUserExistsMiddleware. The fetched row also feeds
// the context stub: the user's timezone must travel with every
// request so server-side time rendering converts per-user.
liveUser, exists := fetchLiveUser(cfg, userUUID)
if !exists {
log.Printf("Ghost session rejected: user %s no longer exists", userIDStr)
c.SetCookie(&http.Cookie{
Name: "token",
Value: "",
Expires: time.Now().Add(-24 * time.Hour),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
if wantsHTML(c.Request().Header) {
return c.Redirect(http.StatusFound, "/login?session=invalid")
}
// Non-nil error is REQUIRED: returning c.JSON's nil would
// let the chain continue into handlers whose c.Get("user")
// assertion panics on the never-set user.
return echo.NewHTTPError(http.StatusUnauthorized,
"user_not_found: this account no longer exists; please log in again")
}
c.Set("user", database.Users{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Email: claims["user_email"].(string),
Username: claims["user_username"].(string),
Role: claims["user_role"].(string),
Timezone: liveUser.Timezone,
})
return nil
},
@@ -138,43 +168,19 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
})
}
// ensureUserExistsMiddleware checks if the authenticated user still exists in the database
func ensureUserExistsMiddleware(cfg *Config) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
userIDStr, ok := c.Get("user_id").(string)
if !ok {
return next(c)
}
// userExists checks the users table for the token's claimed user. Runs on
// every authenticated request: a primary-key lookup, cheap at this scale,
// and it makes deletion revocable the moment it happens.
func userExists(cfg *Config, id uuid.UUID) bool {
_, err := cfg.Queries.GetUser(context.Background(), uuidToPGType(id))
return err == nil
}
userUUID, err := uuid.Parse(userIDStr)
if err != nil {
log.Printf("Invalid UUID in user existence check: %v", err)
return next(c)
}
// Check if user exists in database
_, err = cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
if err != nil {
log.Printf("User not found in database: %s", userIDStr)
// Clear invalid cookie
c.SetCookie(&http.Cookie{
Name: "token",
Value: "",
Expires: time.Now().Add(-24 * time.Hour),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
// Redirect to login with session=invalid
return c.Redirect(http.StatusFound, "/login?session=invalid")
}
return next(c)
}
}
// fetchLiveUser loads the full user row for the JWT stub — the ghost-session
// guard and the timezone travel together on this one per-request lookup.
func fetchLiveUser(cfg *Config, id uuid.UUID) (database.GetUserRow, bool) {
u, err := cfg.Queries.GetUser(context.Background(), uuidToPGType(id))
return u, err == nil
}
// wantsHTML determines if the request expects HTML response
@@ -255,6 +261,21 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
registerWebSocketRoutes(cfg)
registerFrontendRoutes(cfg)
registerDocumentationRoutes(cfg)
// Static bundles are served unhashed (reader.js, index.js), so the
// browser must revalidate on every load — with no Cache-Control it
// fell back to heuristic caching and served stale bundles for hours
// after a deploy (user-visible: fixed web bugs "not fixed"). no-cache
// still allows cheap If-Modified-Since 304 revalidation.
e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
if strings.HasPrefix(c.Request().URL.Path, "/static/") {
c.Response().Header().Set(echo.HeaderCacheControl, "no-cache")
}
return next(c)
}
})
e.Static("/static", "web/static")
// Catch-all 404 handler - must be last
+30 -3
View File
@@ -4,13 +4,19 @@ import (
"bookhoard/internal/database"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
)
// ErrCollectionNameTaken: the user already has a collection with this name.
var ErrCollectionNameTaken = errors.New("collection name already exists")
// Rule represents an auto-assign rule for collections
type Rule struct {
ID string `json:"id"`
@@ -72,7 +78,13 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
})
if err != nil {
return database.Collections{}, fmt.Errorf("failed to create collection: %v", err)
// Duplicate name (UNIQUE(user_id, name)) is a client error, not a
// server failure: surface it as 409 so callers can recover.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return database.Collections{}, ErrCollectionNameTaken
}
return database.Collections{}, fmt.Errorf("failed to create collection: %w", err)
}
return collection, nil
@@ -160,9 +172,24 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
// DeleteCollection deletes a collection
func (s *CollectionService) DeleteCollection(ctx context.Context, collectionID uuid.UUID) error {
err := s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
// System collections (the 5 dashboard sections) are PERMANENT: a
// delete tombstones the row (invisible everywhere, restorable with
// the same id) instead of destroying it and every reference to it.
row, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err != nil {
return fmt.Errorf("failed to delete collection: %v", err)
if errors.Is(err, pgx.ErrNoRows) {
return nil // already gone: keep delete idempotent
}
return fmt.Errorf("failed to fetch collection: %w", err)
}
if row.IsSystemCollection.Bool {
return s.db.TombstoneSystemCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
}
err = s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err != nil {
return fmt.Errorf("failed to delete collection: %w", err)
}
return nil
+76 -24
View File
@@ -5,9 +5,11 @@ import (
"bookhoard/internal/utils"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
@@ -376,6 +378,22 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
}
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
// Absent library = the global (All Libraries) scope: a separate
// one-row-per-user table, so an accidental per-library write can never
// masquerade as global.
if !libraryID.Valid {
g, err := s.db.GetGlobalDashboardPreferences(ctx, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return database.UserDashboardPreferences{}, err
}
return database.UserDashboardPreferences{
UserID: g.UserID,
HiddenCollections: g.HiddenCollections,
CollectionOrder: g.CollectionOrder,
ItemsPerSection: g.ItemsPerSection,
UpdatedAt: g.UpdatedAt,
}, nil
}
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: libraryID,
@@ -383,6 +401,24 @@ func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID u
}
func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) {
if !params.LibraryID.Valid {
g, err := s.db.UpsertGlobalDashboardPreferences(ctx, database.UpsertGlobalDashboardPreferencesParams{
UserID: params.UserID,
HiddenCollections: params.HiddenCollections,
CollectionOrder: params.CollectionOrder,
ItemsPerSection: params.ItemsPerSection,
})
if err != nil {
return database.UserDashboardPreferences{}, err
}
return database.UserDashboardPreferences{
UserID: g.UserID,
HiddenCollections: g.HiddenCollections,
CollectionOrder: g.CollectionOrder,
ItemsPerSection: g.ItemsPerSection,
UpdatedAt: g.UpdatedAt,
}, nil
}
return s.db.UpsertDashboardPreferences(ctx, params)
}
@@ -430,34 +466,50 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u
return fmt.Errorf("unknown collection: %s", collectionName)
}
if resetType == "keep_books" {
return s.db.ResetSystemCollectionMetadata(ctx, database.ResetSystemCollectionMetadataParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Name: collectionName,
Description: pgtype.Text{String: meta.Description, Valid: true},
Icon: pgtype.Text{String: meta.Icon, Valid: true},
Color: pgtype.Text{String: meta.Color, Valid: true},
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
})
}
err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Name: collectionName,
// System collections are PERMANENT rows identified by the stable
// (user_id, query_type) pair — restore never deletes or re-creates,
// so the row id (and every reference to it: device shelf mappings,
// dashboard order/hidden prefs, /collections/{id} links) never churns.
// Resolution includes tombstoned rows: restoring a deleted section
// un-deletes the exact same row.
row, err := s.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
})
if err != nil {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
_, err = s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Name: collectionName,
Description: pgtype.Text{String: meta.Description, Valid: true},
Icon: pgtype.Text{String: meta.Icon, Valid: true},
Color: pgtype.Text{String: meta.Color, Valid: true},
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
if errors.Is(err, pgx.ErrNoRows) {
// Provision: the only path that mints a system-collection row.
_, cerr := s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Name: collectionName,
Description: pgtype.Text{String: meta.Description, Valid: true},
Icon: pgtype.Text{String: meta.Icon, Valid: true},
Color: pgtype.Text{String: meta.Color, Valid: true},
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
})
return cerr
}
// "full": clear item assignments (a fresh section has none) and reset
// everything, IN PLACE. "keep_books": metadata-only, in place. Both
// un-tombstone, so a deleted section comes back as the same row.
if resetType != "keep_books" {
if err := s.db.ClearCollectionItems(ctx, row.ID); err != nil {
return err
}
}
_, err = s.db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
ID: row.ID,
Name: collectionName,
Description: pgtype.Text{String: meta.Description, Valid: true},
Icon: pgtype.Text{String: meta.Icon, Valid: true},
Color: pgtype.Text{String: meta.Color, Valid: true},
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
})
return err
}
+8 -3
View File
@@ -30,7 +30,7 @@ const (
)
var AllowedExtensions = map[string][]string{
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".doc", ".docx", ".lit", ".fb2", ".pdb"},
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".docx", ".fb2", ".pdb"},
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
LibraryTypeManga: {".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
}
@@ -59,9 +59,7 @@ var MimeTypes = map[string]string{
".azw3": "application/vnd.amazon.ebook",
".txt": "text/plain",
".rtf": "application/rtf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".lit": "application/x-msreader",
".fb2": "application/x-fictionbook+xml",
".pdb": "application/vnd.palm",
}
@@ -301,6 +299,13 @@ func (s *LibraryService) SyncAllowedExtensions(ctx context.Context) {
}
func (s *LibraryService) ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
// Typed-nil interface guard: a nil *LibraryService held in an
// interface passes `!= nil` checks at call sites; degrade instead
// of panicking (kobo markup 500s were exactly this path).
if s == nil || s.db == nil {
return "", fmt.Errorf("library service not configured")
}
// Get library folders for this library
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
if err != nil || len(folders) == 0 {
+56 -26
View File
@@ -662,8 +662,8 @@ func (s *MediaScanner) extractFolderStructureMetadata(path, rootFolder string) *
var bookExtensions = map[string]bool{
".epub": true, ".pdf": true, ".mobi": true, ".azw": true, ".azw3": true,
".fb2": true, ".txt": true, ".rtf": true, ".doc": true, ".docx": true,
".lit": true, ".pdb": true, ".djvu": true,
".fb2": true, ".txt": true, ".rtf": true, ".docx": true,
".pdb": true, ".djvu": true,
".cbz": true, ".cbr": true, ".cb7": true, ".cbt": true,
}
@@ -974,30 +974,12 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
// Set format group, total characters, and chapter count
mimeType := s.getMimeType(path)
ext := strings.ToLower(filepath.Ext(path))
var formatGroup string
var isReflowable, hasFixedLayout bool
switch ext {
case ".epub":
var epubIsFixedLayout bool
if ext == ".epub" {
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
if fixedErr == nil && isFixed {
formatGroup = "fixed_layout"
hasFixedLayout = true
} else {
formatGroup = "reflowable"
isReflowable = true
}
case ".mobi", ".azw", ".azw3", ".fb2", ".txt":
formatGroup = "reflowable"
isReflowable = true
case ".pdf", ".djvu":
formatGroup = "fixed_layout"
hasFixedLayout = true
case ".cbz", ".cbr", ".cb7", ".cbt":
formatGroup = "comic_archive"
hasFixedLayout = true
default:
formatGroup = "unknown"
epubIsFixedLayout = fixedErr == nil && isFixed
}
formatGroup, isReflowable, hasFixedLayout := classifyFormatGroup(ext, epubIsFixedLayout)
err = s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
ID: createdItem.ID,
FormatGroup: formatGroup,
@@ -3170,8 +3152,34 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
utils.ApplyMetadataOverrides(&params, existing)
params.MetadataOverrides = utils.MergeOverrides(existing.MetadataOverrides)
_, err = s.db.UpdateMediaItem(ctx, params)
return err
if _, err = s.db.UpdateMediaItem(ctx, params); err != nil {
return err
}
// Format flags are file-derived facts, not user metadata: refresh them on
// every update so a force rescan (the admin's backfill tool) can
// reclassify rows created before a classifyFormatGroup arm existed.
// Mirrors the creation path above.
ext := strings.ToLower(filepath.Ext(path))
var epubIsFixedLayout bool
if ext == ".epub" {
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
epubIsFixedLayout = fixedErr == nil && isFixed
}
formatGroup, isReflowable, hasFixedLayout := classifyFormatGroup(ext, epubIsFixedLayout)
mimeType := s.getMimeType(path)
if err := s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
ID: existing.ID,
FormatGroup: formatGroup,
FormatMimetype: pgtype.Text{String: mimeType, Valid: mimeType != ""},
IsReflowable: pgtype.Bool{Bool: isReflowable, Valid: true},
HasFixedLayout: pgtype.Bool{Bool: hasFixedLayout, Valid: true},
TotalCharacters: pgtype.Int8{Int64: metadata.TotalCharacters, Valid: metadata.TotalCharacters > 0},
ChapterCount: pgtype.Int4{Int32: metadata.ChapterCount, Valid: metadata.ChapterCount > 0},
}); err != nil {
fmt.Printf("Warning: failed to update format info for %s: %v\n", path, err)
}
return nil
}
// RescanMediaItem re-extracts metadata for a single media item and updates it.
@@ -3880,6 +3888,28 @@ func (s *MediaScanner) detectFormatType(filePath string) string {
}
}
// classifyFormatGroup maps a lowercased file extension to the format
// group and layout flags stored on the media item. `.epub` is passed in
// with its detected fixed-layout flag because that check needs the file
// itself.
func classifyFormatGroup(ext string, epubIsFixedLayout bool) (formatGroup string, isReflowable, hasFixedLayout bool) {
switch ext {
case ".epub":
if epubIsFixedLayout {
return "fixed_layout", false, true
}
return "reflowable", true, false
case ".mobi", ".azw", ".azw3", ".fb2", ".txt", ".docx", ".rtf", ".pdb":
return "reflowable", true, false
case ".pdf", ".djvu":
return "fixed_layout", false, true
case ".cbz", ".cbr", ".cb7", ".cbt":
return "comic_archive", false, true
default:
return "unknown", false, false
}
}
// extractHashInfo calculates hash and extracts OPF identifiers for a file
func (s *MediaScanner) extractHashInfo(filePath string) (*HashInfo, *FormatInfo, error) {
// Calculate SHA-256
+33
View File
@@ -119,3 +119,36 @@ func TestProcessDirtyDirectories_CollectsReadyDirs(t *testing.T) {
scanner.dirtyDirsMu.RUnlock()
assert.Equal(t, 0, count, "Ready directory should be removed from dirty list")
}
func TestClassifyFormatGroup(t *testing.T) {
tests := []struct {
name string
ext string
epubFixed bool
group string
reflow bool
fixed bool
}{
{"EPUB reflowable", ".epub", false, "reflowable", true, false},
{"EPUB fixed-layout", ".epub", true, "fixed_layout", false, true},
{"MOBI", ".mobi", false, "reflowable", true, false},
{"FB2", ".fb2", false, "reflowable", true, false},
{"TXT", ".txt", false, "reflowable", true, false},
{"DOCX (was unknown before the fix)", ".docx", false, "reflowable", true, false},
{"RTF", ".rtf", false, "reflowable", true, false},
{"PDB (PalmDOC)", ".pdb", false, "reflowable", true, false},
{"PDF", ".pdf", false, "fixed_layout", false, true},
{"DJVU", ".djvu", false, "fixed_layout", false, true},
{"CBZ", ".cbz", false, "comic_archive", false, true},
{"unrecognized extension", ".xyz", false, "unknown", false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
group, reflow, fixed := classifyFormatGroup(tt.ext, tt.epubFixed)
if group != tt.group || reflow != tt.reflow || fixed != tt.fixed {
t.Errorf("classifyFormatGroup(%q, %v) = (%q, %v, %v), want (%q, %v, %v)",
tt.ext, tt.epubFixed, group, reflow, fixed, tt.group, tt.reflow, tt.fixed)
}
})
}
}
+27 -25
View File
@@ -22,20 +22,21 @@ func NewSearchService(db *database.Queries) *SearchService {
// SearchParams contains parameters for unified search
type SearchParams struct {
UserID pgtype.UUID
LibraryID pgtype.UUID
AuthorFilter string
SeriesFilter string
GenreFilter string
TagsFilter string
LanguageFilter string
YearMin int
YearMax int
HasCover pgtype.Bool
SearchQuery string
Sort string
Limit int
Offset int
UserID pgtype.UUID
LibraryID pgtype.UUID
AuthorFilter string
SeriesFilter string
GenreFilter string
PublisherFilter string
TagsFilter string
LanguageFilter string
YearMin int
YearMax int
HasCover pgtype.Bool
SearchQuery string
Sort string
Limit int
Offset int
}
// parseSearchQuery detects quoted strings for exact match search
@@ -69,17 +70,18 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
// Build database parameters - only set valid true if filter
dbParams := database.SearchMediaItemsUnifiedParams{
UserID: params.UserID,
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
HasCover: params.HasCover,
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
UserID: params.UserID,
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
HasCover: params.HasCover,
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
// SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact},
Sort: pgtype.Text{String: params.Sort, Valid: true},
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
+40 -8
View File
@@ -5,11 +5,13 @@ import (
"bookhoard/internal/utils"
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type SeriesInfo struct {
Name string
LibraryID string
BookCount int64
TotalInSeries int
CoverPaths []string
@@ -24,16 +26,25 @@ func NewSeriesService(db *database.Queries) *SeriesService {
return &SeriesService{db: db}
}
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, limit, offset int) ([]SeriesInfo, int, error) {
totalCount, err := s.db.GetDistinctSeriesCount(ctx, libraryID)
// GetSeriesPage lists series grouped PER LIBRARY (same name in two
// libraries = two entries) filtered to the requester's visible libraries.
// Pass libraryID for a single-library page, or leave it invalid and pass
// visibleLibraryIDs for an across-my-libraries page. Both filters compose:
// the handler validates any explicit library_id against the visible set.
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]SeriesInfo, int, error) {
totalCount, err := s.db.GetDistinctSeriesCount(ctx, database.GetDistinctSeriesCountParams{
LibraryID: libraryID,
VisibleLibraryIds: visibleLibraryIDs,
})
if err != nil {
return nil, 0, err
}
rows, err := s.db.GetDistinctSeries(ctx, database.GetDistinctSeriesParams{
LibraryID: libraryID,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
LibraryID: libraryID,
VisibleLibraryIds: visibleLibraryIDs,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
})
if err != nil {
return nil, 0, err
@@ -42,7 +53,7 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
var series []SeriesInfo
for _, row := range rows {
seriesName := row.Series.String
coverPaths, _ := s.GetSeriesCovers(ctx, libraryID, seriesName, 7)
coverPaths, _ := s.GetSeriesCovers(ctx, row.LibraryID, seriesName, 7)
totalInSeries := 0
if row.TotalInSeries != nil {
@@ -71,6 +82,7 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
series = append(series, SeriesInfo{
Name: seriesName,
LibraryID: uuidFromPG(row.LibraryID),
BookCount: row.BookCount,
TotalInSeries: totalInSeries,
CoverPaths: coverPaths,
@@ -106,8 +118,28 @@ func (s *SeriesService) GetSeriesCovers(ctx context.Context, libraryID pgtype.UU
return paths, nil
}
func (s *SeriesService) GetSeriesBooks(ctx context.Context, seriesName string) ([]database.MediaItems, error) {
return s.db.GetSeriesBooks(ctx, pgtype.Text{String: seriesName, Valid: true})
func (s *SeriesService) GetSeriesBooks(ctx context.Context, libraryID pgtype.UUID, seriesName string) ([]database.MediaItems, error) {
return s.db.GetSeriesBooks(ctx, database.GetSeriesBooksParams{
Series: pgtype.Text{String: seriesName, Valid: true},
LibraryID: libraryID,
})
}
// VisibleLibraries exposes the per-user visibility set used by the series
// handlers to scope listings and validate explicit library_id params.
func (s *SeriesService) VisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]database.GetUserVisibleLibrariesRow, error) {
return s.db.GetUserVisibleLibraries(ctx, userID)
}
func uuidFromPG(id pgtype.UUID) string {
if !id.Valid {
return ""
}
u, err := uuid.FromBytes(id.Bytes[0:16])
if err != nil {
return ""
}
return u.String()
}
func continueSeriesRowToMediaItems(row database.GetContinueSeriesItemsRow) database.MediaItems {
+26 -6
View File
@@ -258,6 +258,7 @@ func (s *AnnotationService) applyLWW(
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
UserID: req.UserID,
})
if err != nil {
return nil, fmt.Errorf("update highlight: %w", err)
@@ -328,14 +329,22 @@ func (s *AnnotationService) TombstoneHighlight(
func (s *AnnotationService) TombstoneHighlightByID(
ctx context.Context,
highlightID pgtype.UUID,
userID, highlightID pgtype.UUID,
source string,
) error {
h, err := s.db.GetMediaHighlight(ctx, highlightID)
if err != nil {
return fmt.Errorf("get highlight for tombstone: %w", err)
}
err = s.db.TombstoneMediaHighlightByID(ctx, highlightID)
// Ownership predicate: the caller must own the row. A mismatched user
// gets a not-found-style error, never the row's existence.
if h.UserID != userID {
return fmt.Errorf("tombstone highlight by ID: %w", pgx.ErrNoRows)
}
err = s.db.TombstoneMediaHighlightByID(ctx, database.TombstoneMediaHighlightByIDParams{
ID: highlightID,
UserID: userID,
})
if err != nil {
return fmt.Errorf("tombstone highlight by ID: %w", err)
}
@@ -646,8 +655,11 @@ func (s *AnnotationService) compareIncomingNote(req SaveNoteRequest, existing da
return req.ModifiedAt.After(existingMod.Time), true
}
func (s *AnnotationService) TombstoneNoteByID(ctx context.Context, id pgtype.UUID) error {
return s.db.TombstoneMediaNoteByID(ctx, id)
func (s *AnnotationService) TombstoneNoteByID(ctx context.Context, id, userID pgtype.UUID) error {
return s.db.TombstoneMediaNoteByID(ctx, database.TombstoneMediaNoteByIDParams{
ID: id,
UserID: userID,
})
}
type SaveBookmarkRequest struct {
@@ -788,6 +800,7 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
UserID: req.UserID,
})
if err != nil {
return nil, fmt.Errorf("update bookmark: %w", err)
@@ -817,12 +830,19 @@ func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, exi
return req.ModifiedAt.After(existingMod.Time), true
}
func (s *AnnotationService) TombstoneBookmarkByID(ctx context.Context, bookmarkID pgtype.UUID, source string) error {
func (s *AnnotationService) TombstoneBookmarkByID(ctx context.Context, userID, bookmarkID pgtype.UUID, source string) error {
bm, err := s.db.GetMediaBookmark(ctx, bookmarkID)
if err != nil {
return fmt.Errorf("get bookmark for tombstone: %w", err)
}
err = s.db.TombstoneMediaBookmarkByID(ctx, bookmarkID)
// Ownership predicate: the caller must own the row.
if bm.UserID != userID {
return fmt.Errorf("tombstone bookmark by ID: %w", pgx.ErrNoRows)
}
err = s.db.TombstoneMediaBookmarkByID(ctx, database.TombstoneMediaBookmarkByIDParams{
ID: bookmarkID,
UserID: userID,
})
if err != nil {
return fmt.Errorf("tombstone bookmark by ID: %w", err)
}
+127 -28
View File
@@ -431,7 +431,9 @@ func firstTextDescendant(n *html.Node) *html.Node {
// textNodeAtRuneOffset walks text nodes under elem in document order and
// returns the node containing the rune offset plus the local offset within
// that node. Offsets beyond the end clamp to the last node.
func textNodeAtRuneOffset(elem *html.Node, offset int) (*html.Node, int) {
// textNodeAtUTF16Offset resolves a crengine text().N offset — UTF-16 code
// units — to (text node, rune offset) within elem's text.
func textNodeAtUTF16Offset(elem *html.Node, offset int) (*html.Node, int) {
if offset < 0 {
offset = 0
}
@@ -441,15 +443,15 @@ func textNodeAtRuneOffset(elem *html.Node, offset int) (*html.Node, int) {
var walk func(*html.Node) bool
walk = func(node *html.Node) bool {
if node.Type == html.TextNode {
length := utf8.RuneCountInString(node.Data)
length := utf16Len(node.Data)
if remaining < length {
target = node
local = remaining
local = utf16ToRuneIndex(node.Data, remaining)
return true
}
remaining -= length
target = node
local = length
local = utf8.RuneCountInString(node.Data)
return false
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
@@ -507,7 +509,7 @@ func (c *CFIConverter) convertByStructuralPath(body *html.Node, xp *CREXPointer,
var textNode *html.Node
var localOffset int
if xp.CharOffset > 0 {
textNode, localOffset = textNodeAtRuneOffset(elem, xp.CharOffset)
textNode, localOffset = textNodeAtUTF16Offset(elem, xp.CharOffset)
} else {
textNode = firstTextDescendant(elem)
localOffset = 0
@@ -624,7 +626,12 @@ func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
lastBlock = block
off := 0
for _, r := range n.Data {
if unicode.IsSpace(r) {
// Zero-width format characters are line-break opportunities,
// not word characters: devices render them as breaks and
// capture plain spaces, so they must not fuse words in the
// search stream. ZWJ (U+200D) is exempt — it glues emoji
// sequences.
if unicode.IsSpace(r) || r == '\u200b' || r == '\u200c' || r == '\ufeff' {
pendingSpace = true
} else {
if pendingSpace && len(runes) > 0 {
@@ -642,9 +649,12 @@ func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
var walk func(n *html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
if strings.TrimSpace(n.Data) != "" {
appendNode(n)
}
// Whitespace-only nodes still participate: a space carried in
// its own inline span (<span class="sp"> </span>) is a real
// word separator, and skipping it fuses adjacent words in the
// search stream. appendNode turns such nodes into pending
// space without emitting anything.
appendNode(n)
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
@@ -1058,24 +1068,96 @@ func indexChildNodes(parent *html.Node) []indexedNode {
return nodes
}
func findTextChunkIndex(parent *html.Node, textNode *html.Node) (int, int) {
// Character-offset currency policy: the EPUB CFI spec and crengine both
// count UTF-16 code units (JavaScript `.length` semantics — what foliate,
// readium, KOReader and Kobo clients all observe), so every offset that
// CROSSES the wire — CFI terminals, CRE text() offsets, the served
// char_offset handle — is UTF-16. Internal arithmetic (book-level
// character_offset, percentage fractions) stays rune-based, consistent
// with TotalCharacters. These helpers convert at the boundaries; for
// all-BMP text the two currencies are identical, so ASCII books are
// unaffected.
// utf16Len returns the UTF-16 code-unit length of s.
func utf16Len(s string) int {
n := 0
for _, r := range s {
if r >= 0x10000 {
n += 2
} else {
n++
}
}
return n
}
// utf16ToRuneIndex converts a UTF-16 code-unit offset within s to a rune
// index (clamped to len(runes)).
func utf16ToRuneIndex(s string, u16 int) int {
if u16 <= 0 {
return 0
}
units := 0
i := 0
for _, r := range s {
if units >= u16 {
return i
}
if r >= 0x10000 {
units += 2
} else {
units++
}
i++
}
return i
}
// runeToUTF16Index converts a rune index within s to a UTF-16 code-unit
// offset (clamped to the string's unit length).
func runeToUTF16Index(s string, runeIdx int) int {
if runeIdx <= 0 {
return 0
}
units := 0
i := 0
for _, r := range s {
if i >= runeIdx {
return units
}
if r >= 0x10000 {
units += 2
} else {
units++
}
i++
}
return units
}
// findTextChunk locates the indexed text chunk containing textNode and
// returns its chunk index, the rune offset of the node within the chunk,
// and the chunk text up to and including the node (for UTF-16 conversion
// of chunk-relative offsets).
func findTextChunk(parent *html.Node, textNode *html.Node) (int, int, string) {
indexed := indexChildNodes(parent)
for i, node := range indexed {
if node.isTextChunk() {
for j, tn := range node.textChunk {
var sb strings.Builder
chunkOffset := 0
for _, tn := range node.textChunk {
if tn == textNode {
chunkOffset := 0
for k := 0; k < j; k++ {
chunkOffset += utf8.RuneCountInString(node.textChunk[k].Data)
}
return i, chunkOffset
return i, chunkOffset, sb.String() + textNode.Data
}
chunkOffset += utf8.RuneCountInString(tn.Data)
sb.WriteString(tn.Data)
}
}
}
return -1, 0
return -1, 0, ""
}
func findElementCFIIndex(parent *html.Node, element *html.Node) int {
indexed := indexChildNodes(parent)
for i, node := range indexed {
@@ -1110,15 +1192,17 @@ func buildCFI(spineIndex int, textNode *html.Node, charOffset int) (string, erro
return "", fmt.Errorf("text node has no parent")
}
chunkIdx, chunkOffset := findTextChunkIndex(parent, textNode)
chunkIdx, chunkOffset, chunkText := findTextChunk(parent, textNode)
if chunkIdx == -1 {
return "", fmt.Errorf("text node not found in parent's indexed children")
}
// The CFI terminal offset is UTF-16 code units (spec currency); the
// internal charOffset is runes. Convert over the chunk text.
totalOffset := chunkOffset + charOffset
var parts []string
parts = append(parts, fmt.Sprintf("/%d:%d", chunkIdx, totalOffset))
parts = append(parts, fmt.Sprintf("/%d:%d", chunkIdx, runeToUTF16Index(chunkText, totalOffset)))
current := parent
for current != nil {
@@ -1245,9 +1329,16 @@ func countTextChars(n *html.Node) int {
return count
}
// countTextCharsBefore counts the text characters preceding target within
// its document: preceding siblings at each ancestor level, strictly
// EXCLUDING target's own subtree (callers add the in-node offset
// separately). Starting the walk at target itself — or recursing after
// counting the current node's subtree — would re-count the accumulated
// document once per ancestor level and multiply the result by the node's
// depth.
func countTextCharsBefore(target *html.Node) int {
count := 0
for c := target; c != nil; c = c.PrevSibling {
for c := target.PrevSibling; c != nil; c = c.PrevSibling {
count += countTextChars(c)
}
if target.Parent != nil {
@@ -1519,24 +1610,31 @@ func resolveCFIToNode(doc *html.Node, steps []cfiStep) (*html.Node, int, error)
entry := indexed[lastStep.Index]
if entry.isTextChunk() {
textOffset := 0
// The CFI terminal offset arrives in UTF-16 code units (spec
// currency — foliate/readium/KOReader/Kobo all emit UTF-16).
// Walk the chunk in UTF-16 units, then convert the hit position
// to the internal rune offset.
u16Remaining := 0
if lastStep.HasOffset {
textOffset = lastStep.Offset
u16Remaining = lastStep.Offset
}
var targetNode *html.Node
remainingOffset := textOffset
runeIntoTarget := 0
for _, tn := range entry.textChunk {
textLen := utf8.RuneCountInString(tn.Data)
if remainingOffset < textLen || (remainingOffset == textLen && targetNode == nil) {
units := utf16Len(tn.Data)
if u16Remaining < units || (u16Remaining == units && targetNode == nil) {
targetNode = tn
runeIntoTarget = utf16ToRuneIndex(tn.Data, u16Remaining)
break
}
remainingOffset -= textLen
u16Remaining -= units
targetNode = tn
runeIntoTarget = utf8.RuneCountInString(tn.Data)
}
if targetNode == nil && len(entry.textChunk) > 0 {
targetNode = entry.textChunk[len(entry.textChunk)-1]
runeIntoTarget = utf8.RuneCountInString(targetNode.Data)
}
parent := targetNode.Parent
@@ -1547,7 +1645,7 @@ func resolveCFIToNode(doc *html.Node, steps []cfiStep) (*html.Node, int, error)
}
totalOffset += countTextChars(c)
}
totalOffset += remainingOffset
totalOffset += runeIntoTarget
return targetNode, totalOffset, nil
}
@@ -1606,7 +1704,8 @@ func buildCREXPointer(spineIndex int, node *html.Node, charOffset int) (string,
xpointer := fmt.Sprintf("/body/DocFragment[%d]/body%s", fragIndex, strings.Join(parts, ""))
if charOffset > 0 || (node.Type == html.TextNode) {
xpointer += fmt.Sprintf("/text().%d", charOffset)
// crengine counts UTF-16 code units; charOffset is internal runes.
xpointer += fmt.Sprintf("/text().%d", runeToUTF16Index(node.Data, charOffset))
}
return xpointer, nil
+1 -1
View File
@@ -133,7 +133,7 @@ func writeTestEPUB(t *testing.T) string {
}
docs := []spineDoc{
{"doc1.xhtml", "<body><div><p>Chapter one opening page.</p></div></body>"},
{"doc2.xhtml", "<body><div><p>The family of Dashwood had long been settled in Sussex.</p><p>Their estate was large, and their residence was at Norland Park.</p></div></body>"},
{"doc2.xhtml", "<body><div><p>The family of Dashwood had long been settled in Sussex.</p><p>Their estate was large, and their residence was at Norland Park.</p><p>The family crest shows a globe \U0001F30D and a rocket \U0001F680 flying onward.</p></div></body>"},
{"doc3.xhtml", "<body><div><p>Chapter three contents.</p></div></body>"},
{"doc4.xhtml", "<body><div><p>Chapter four contents.</p></div></body>"},
{"doc5.xhtml", "<body><div><p>Chapter five contents.</p></div></body>"},
+167
View File
@@ -0,0 +1,167 @@
package sync
// Text-anchored range conversion for device highlights.
//
// Why this exists: CRE xpointers address text as text()[N].offset, but
// crengine's text-node enumeration does not survive to the raw XHTML on
// disk (whitespace runs, entity merges, live-DOM splits), and the trailing
// offset alone lands at a global block offset that is wrong for any anchor
// past the first text node of a block. Converting pos0 and pos1
// independently made it worse: mismatched start/end base paths and
// backwards ranges — highlights that store but never paint (or paint in
// the wrong place) in the web reader.
//
// The device's selection text, however, is a quote of the document. So for
// highlights we anchor BOTH endpoints by locating that quote in the
// flattened document text (whitespace-normalized, crossing block
// boundaries), and use the device locators only as disambiguation hints
// when the quote occurs more than once. Endpoints derived from one match
// are same-spine and ordered by construction — the two properties the
// reader's range-CFI builder needs.
import (
"log"
"regexp"
"strings"
"unicode/utf8"
"golang.org/x/net/html"
)
// ConvertHighlightRange resolves a highlight's pos0/pos1 pair into a start
// CFI plus a true range end by locating the selection text in the document.
// It returns nil when the text cannot be located; callers fall back to the
// legacy per-endpoint conversion.
func (c *CFIConverter) ConvertHighlightRange(pos0, pos1, contextText string, storedPercentage float64) *ConversionResult {
if !IsCREXPointer(pos0) {
return nil
}
xp, err := ParseCREXPointer(pos0)
if err != nil {
return nil
}
doc, href, err := c.getContentDoc(xp.FragmentIndex)
if err != nil {
return nil
}
body := findBody(doc)
if body == nil {
return nil
}
spineIndex := xp.FragmentIndex - 1
// KOReader wraps partial-paragraph captures in curly braces; they are
// wire markers, not document text. Zero-width format characters in the
// capture are break opportunities the device rendered — normalize to
// spaces so both sides of the match agree.
ctx := normalizeWhitespace(strings.Trim(contextText, "{}"))
ctx = strings.NewReplacer("\u200b", " ", "\u200c", " ", "\ufeff", " ").Replace(ctx)
ctx = normalizeWhitespace(ctx)
if ctx == "" {
return nil
}
runes, poss := flattenDocumentForSearch(body)
text := string(runes)
words := strings.Fields(ctx)
quoted := make([]string, len(words))
for i, w := range words {
quoted[i] = regexp.QuoteMeta(w)
}
re, err := regexp.Compile(strings.Join(quoted, `\s+`))
if err != nil {
return nil
}
matches := re.FindAllStringIndex(text, -1)
if len(matches) == 0 {
log.Printf("Bookhoard: highlight range: selection %q not found in %s", truncateForLog(ctx, 60), href)
return nil
}
loc := pickHighlightMatch(matches, text, poss, xp, pos1, body)
if len(matches) > 1 {
log.Printf("Bookhoard: highlight range: %d matches for %q in %s, resolved by device hint", len(matches), truncateForLog(ctx, 40), href)
}
startRune := utf8.RuneCountInString(text[:loc[0]])
endRune := utf8.RuneCountInString(text[:loc[1]]) // exclusive
startPos := poss[startRune]
startCFI, err := buildCFI(spineIndex, startPos.node, startPos.off)
if err != nil || startCFI == "" {
log.Printf("Bookhoard: highlight range: buildCFI(start) failed: %v", err)
return nil
}
endCFI := ""
if endRune > startRune && endRune <= len(poss) {
endPos := poss[endRune-1]
endOff := endPos.off + 1
if n := utf8.RuneCountInString(endPos.node.Data); endOff > n {
endOff = n
}
if ec, err := buildCFI(spineIndex, endPos.node, endOff); err == nil && ec != "" {
endCFI = ec
}
}
log.Printf("Bookhoard: highlight range %q → %s (precision: exact)", truncateForLog(ctx, 60), startCFI)
return &ConversionResult{
EPUBCFI: startCFI,
EndEPUBCFI: endCFI,
Href: href,
Percentage: storedPercentage,
Precision: "exact",
}
}
// pickHighlightMatch chooses among multiple occurrences of the selection.
// pos1's element path (the end anchor's landing block) is the strongest
// hint: the true occurrence is the one whose extent stays inside that
// block. Without a usable hint, document order wins (first occurrence).
func pickHighlightMatch(matches [][]int, text string, poss []docRunePos, xp *CREXPointer, pos1 string, body *html.Node) []int {
if len(matches) <= 1 {
return matches[0]
}
if xp1, err := ParseCREXPointer(pos1); err == nil && len(xp1.ElementPath) > 0 {
if elem := resolveCREElementPath(body, xp1.ElementPath); elem != nil {
lo, hi := elementFlatRange(poss, elem)
if lo >= 0 {
for _, m := range matches {
s := utf8.RuneCountInString(text[:m[0]])
e := utf8.RuneCountInString(text[:m[1]]) - 1
if s >= lo && e < hi {
return m
}
}
}
}
}
return matches[0]
}
// elementFlatRange returns the [lo, hi) rune range of the flattened stream
// covered by elem's subtree, or (-1, 0) when elem contributes no text.
func elementFlatRange(poss []docRunePos, elem *html.Node) (int, int) {
lo, hi := -1, -1
for i, p := range poss {
if isDescendantNode(p.node, elem) {
if lo < 0 {
lo = i
}
hi = i + 1
}
}
if lo < 0 {
return -1, 0
}
return lo, hi
}
func isDescendantNode(n, ancestor *html.Node) bool {
for p := n.Parent; p != nil; p = p.Parent {
if p == ancestor {
return true
}
}
return false
}
+379
View File
@@ -0,0 +1,379 @@
package sync
// Manual repro harness for the CRE→CFI highlight conversion defects found in
// "Clean" (Rosser K Powitzky): 28 device highlights whose stored web CFIs are
// structurally inconsistent (mismatched start/end base paths, backwards
// ranges). This test mirrors handlers.convertHighlightPositions exactly —
// pos0 converted WITH the selection text as context, pos1 WITHOUT any — then
// reports the result alongside the CFIs stored at ingest time and a
// ground-truth oracle computed by locating the selection text in the document
// itself. Skips silently when the book/export are not on this machine, like
// cfi_verify_manual_test.go.
import (
"fmt"
"encoding/json"
"os"
"strings"
"testing"
"golang.org/x/net/html"
)
const (
reproExport = "/home/nymusicman/Code/bookhoard/export/media_highlights.json"
reproEpub = "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Rosser K Powitzky/Clean_ How the Jewish Roots of Atone (77)/Clean_ How the Jewish Roots of - Rosser K Powitzky.epub"
)
type reproRow struct {
ID string `json:"id"`
StartPosition string `json:"start_position"`
EndPosition string `json:"end_position"`
StoredStart string `json:"epubcfi_start"`
StoredEnd string `json:"epubcfi_end"`
SelectionText string `json:"selection_text"`
PctStart float64 `json:"percentage_start"`
}
// webUsable mirrors handlers.webUsableCFI (unexported there; cannot import
// the handlers package from here without a cycle).
func webUsable(precision, cfi string) string {
if precision != "structural" && precision != "exact" {
return ""
}
if !strings.HasPrefix(cfi, "epubcfi(") || !strings.HasSuffix(cfi, ")") {
return ""
}
return cfi
}
// reproExtendCFIByLength mirrors handlers.extendCFIByLength: advance the
// trailing character offset of a point CFI by the UTF-16 length of text.
func reproExtendCFIByLength(cfi, text string) string {
if cfi == "" || text == "" {
return ""
}
if !strings.HasSuffix(cfi, ")") {
return ""
}
inner := cfi[:len(cfi)-1]
idx := strings.LastIndex(inner, ":")
if idx < 0 {
return cfi
}
var off int
if _, err := fmtSscanInt(inner[idx+1:], &off); err != nil {
return cfi
}
return inner[:idx+1] + itoa(off+utf16Len(text)) + ")"
}
// cfiBase splits "epubcfi(...)" into (base, offset): base is everything
// before the final ":N" character-offset terminator.
func cfiBase(cfi string) (string, int, bool) {
if !strings.HasSuffix(cfi, ")") {
return "", 0, false
}
inner := cfi[:len(cfi)-1]
idx := strings.LastIndex(inner, ":")
if idx < 0 {
return inner, 0, true
}
off := 0
if _, err := fmtSscanInt(inner[idx+1:], &off); err != nil {
return inner, 0, true
}
return inner[:idx], off, true
}
func fmtSscanInt(s string, out *int) (int, error) {
n := 0
ok := false
for _, r := range s {
if r < '0' || r > '9' {
break
}
n = n*10 + int(r-'0')
ok = true
}
if !ok {
return 0, errReproNoInt
}
*out = n
return len(s), nil
}
var errReproNoInt = errReproNoIntErr{}
type errReproNoIntErr struct{}
func (errReproNoIntErr) Error() string { return "no integer" }
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
// oracleCFIs locates the selection text in the document named by the start
// xpointer's fragment and returns ground-truth (startCFI, endCFI, spineIndex).
// Whitespace runs are collapsed on both sides before matching, so device
// text with reflowed newlines still lands.
func oracleCFIs(t *testing.T, conv *CFIConverter, row reproRow) (string, string, string) {
xp, err := ParseCREXPointer(row.StartPosition)
if err != nil {
return "", "", "unparseable start xpointer"
}
doc, href, err := conv.getContentDoc(xp.FragmentIndex)
if err != nil {
return "", "", "content doc missing: " + err.Error()
}
body := findBody(doc)
if body == nil {
return "", "", "no body"
}
// Flatten every text node in document order.
var segs []textSegment
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
segs = append(segs, textSegment{node: n, runes: []rune(n.Data)})
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(body)
var flat []rune
for _, s := range segs {
flat = append(flat, s.runes...)
}
needle := []rune(normalizeWhitespace(row.SelectionText))
if len(needle) == 0 {
return "", "", "empty selection text"
}
// Collapse whitespace in `flat` while mapping each kept rune back to a
// flat index.
var norm []rune
var normToFlat []int
lastWS := false
for i, r := range flat {
isWS := r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == ' ' || r == '​'
if isWS {
if lastWS {
continue
}
norm = append(norm, ' ')
normToFlat = append(normToFlat, i)
lastWS = true
continue
}
norm = append(norm, r)
normToFlat = append(normToFlat, i)
lastWS = false
}
// Trim wrapped-quote artifacts KOReader adds to partial-paragraph text.
trimmed := needle
for len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '}') {
trimmed = trimmed[1:]
}
for len(trimmed) > 0 && (trimmed[len(trimmed)-1] == '{' || trimmed[len(trimmed)-1] == '}') {
trimmed = trimmed[:len(trimmed)-1]
}
pos := indexRunes(norm, trimmed)
if pos < 0 {
return "", "", "selection text not found in doc " + href
}
flatStart := normToFlat[pos]
flatEnd := normToFlat[pos+len(trimmed)-1] + 1 // end is exclusive
nodeAt := func(flatIdx int) (*html.Node, int) {
acc := 0
for _, s := range segs {
n := len(s.runes)
if flatIdx < acc+n {
return s.node, flatIdx - acc
}
acc += n
}
return nil, 0
}
startNode, startOff := nodeAt(flatStart)
endNode, endOff := nodeAt(flatEnd)
if startNode == nil || endNode == nil {
return "", "", "flat index out of range"
}
spineIndex := xp.FragmentIndex - 1
startCFI, err := buildCFI(spineIndex, startNode, startOff)
if err != nil {
return "", "", "buildCFI start: " + err.Error()
}
endCFI, err := buildCFI(spineIndex, endNode, endOff)
if err != nil {
return "", "", "buildCFI end: " + err.Error()
}
return startCFI, endCFI, ""
}
func indexRunes(hay, needle []rune) int {
if len(needle) == 0 || len(needle) > len(hay) {
return -1
}
for i := 0; i+len(needle) <= len(hay); i++ {
match := true
for j := range needle {
if hay[i+j] != needle[j] {
match = false
break
}
}
if match {
return i
}
}
return -1
}
func TestManualCleanRepro(t *testing.T) {
raw, err := os.ReadFile(reproExport)
if err != nil {
t.Skipf("export json not present: %v", err)
}
if _, err := os.Stat(reproEpub); err != nil {
t.Skipf("book epub not present: %v", err)
}
var rows []reproRow
if err := json.Unmarshal(raw, &rows); err != nil {
t.Fatalf("unmarshal export: %v", err)
}
conv := cachedConverter(reproEpub)
stats := map[string]int{}
for _, row := range rows {
// Mirror handlers.convertHighlightPositions (text-anchored primary
// path, legacy per-endpoint fallback).
startLoc := ConvertHighlightRangeToCanonical(reproEpub, "reflowable", row.StartPosition, row.EndPosition, row.SelectionText, row.PctStart)
startCFI := webUsable(startLoc.Precision, startLoc.CFI)
endCFI := ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = webUsable(startLoc.Precision, startLoc.EndCFI)
}
if startCFI == "" {
startLoc = ConvertToCanonical(LocatorSourceKOReader, row.StartPosition, row.PctStart, row.SelectionText, "reflowable", reproEpub, "")
startCFI = webUsable(startLoc.Precision, startLoc.CFI)
endCFI = ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = startLoc.EndCFI
}
if endCFI == "" && row.EndPosition != "" {
endLoc := ConvertToCanonical(LocatorSourceKOReader, row.EndPosition, row.PctStart, "", "reflowable", reproEpub, "")
endCFI = webUsable(endLoc.Precision, endLoc.CFI)
}
if endCFI == "" && startCFI != "" && row.SelectionText != "" {
endCFI = reproExtendCFIByLength(startCFI, row.SelectionText)
}
}
oracleStart, oracleEnd, oracleNote := oracleCFIs(t, conv, row)
stats["oracleMiss:"+statusOr(oracleNote)]++
cls := func(a, b string) string {
ba, oa, okA := cfiBase(a)
bb, ob, okB := cfiBase(b)
switch {
case !okA || !okB || a == "" || b == "":
return "missing"
case ba != bb:
return "diff-base"
case ob <= oa:
return "backwards"
default:
return "healthy"
}
}
cur := cls(startCFI, endCFI)
sto := cls(row.StoredStart, row.StoredEnd)
ora := cls(oracleStart, oracleEnd)
stats["current:"+cur]++
stats["stored:"+sto]++
stats["oracle:"+ora]++
if os.Getenv("REPRO_VERBOSE") != "" || cur != "healthy" || sto != "healthy" {
t.Logf("row %s", row.ID[:8])
t.Logf(" raw: %s -> %s", row.StartPosition, row.EndPosition)
t.Logf(" ctx: %q", truncateRunes(row.SelectionText, 60))
t.Logf(" stored: %-38s %-38s [%s]", row.StoredStart, row.StoredEnd, sto)
t.Logf(" current: %-38s %-38s [%s]", startCFI, endCFI, cur)
t.Logf(" oracle: %-38s %-38s [%s] %s", oracleStart, oracleEnd, ora, oracleNote)
}
}
t.Logf("summary: %v", stats)
// REPRO_EMIT_SQL=<path>: emit UPDATE statements carrying the NEW
// converter output into the database rows this export came from
// (one-off healing of rows written by the broken converter).
if out := os.Getenv("REPRO_EMIT_SQL"); out != "" {
var b strings.Builder
b.WriteString("BEGIN;\n")
for _, row := range rows {
startLoc := ConvertHighlightRangeToCanonical(reproEpub, "reflowable", row.StartPosition, row.EndPosition, row.SelectionText, row.PctStart)
startCFI := webUsable(startLoc.Precision, startLoc.CFI)
endCFI := ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = webUsable(startLoc.Precision, startLoc.EndCFI)
}
if startCFI == "" {
continue // leave untouched; legacy fallback rows keep old CFIs
}
b.WriteString(fmt.Sprintf(
"UPDATE media_highlights SET epubcfi_start=%s, epubcfi_end=%s WHERE id='%s';\n",
pgQuote(startCFI), pgQuote(endCFI), row.ID,
))
}
b.WriteString("COMMIT;\n")
if err := os.WriteFile(out, []byte(b.String()), 0o600); err != nil {
t.Fatalf("write sql: %v", err)
}
t.Logf("wrote %s", out)
}
}
func pgQuote(s string) string {
if s == "" {
return "''"
}
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func statusOr(s string) string {
if s == "" {
return "found"
}
return s
}
+349
View File
@@ -0,0 +1,349 @@
package sync
// Ingest-side position authority for reading progress.
//
// Clients submit (percentage, context_text, epubcfi). The epubcfi is a
// standard wrapped CFI — epubcfi(/6/N!/…) — resolvable against the same
// document this package parses, so every submission is INDEPENDENTLY
// verified: the text at the resolved anchor is extracted and compared
// with the submitted context. A mismatch (or an unresolvable anchor)
// heals the position by text search, with the submitted percentage
// disambiguating repeated phrases, instead of trusting a client-side
// projection. This keeps buggy clients from poisoning stored positions:
// a resolver that silently returns "wherever I'm currently scrolled"
// fails the context check and gets healed to the true location.
//
// The anchor's block element is also derived as a cssSelector plus a
// block-relative character offset and served back — the readium-native
// handle clients scroll to, so they never have to parse or trust CFIs
// themselves.
//
// Note: readium-based clients number their readingOrder excluding
// linear="no" spine items, while this package's spine index follows the
// OPF spine as written. The spine index is therefore internal-only;
// client-facing responses carry the anchor document's href instead.
import (
"fmt"
"strings"
"unicode/utf8"
"golang.org/x/net/html"
)
// IsConvertibleFormat reports whether a format group uses standard CFIs
// as its structural locator currency (i.e. whether CFI verification and
// cssSelector derivation apply to it).
func IsConvertibleFormat(formatGroup string) bool {
return isConvertible(string(formatGroup))
}
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
// anchorBlock returns the nearest non-inline (block-level) ancestor of a
// resolved text/element node — the server-side equivalent of the
// readers' computed-style block walk.
func anchorBlock(node *html.Node) *html.Node {
if node == nil {
return nil
}
if node.Type == html.ElementNode && !isInlineFormatting(node) {
return node
}
return findBlockParent(node)
}
// blockContextText extracts the normalized text from (node, runeOff) to
// the end of the anchor block — the same excerpt rule the readers use for
// context_text, ≤100 chars.
func blockContextText(block, node *html.Node, runeOff int) string {
segments := collectInlineText(block)
var sb strings.Builder
started := false
for _, seg := range segments {
if !started && seg.node == node {
started = true
runes := []rune(string(seg.runes))
if runeOff < len(runes) {
sb.WriteString(string(runes[runeOff:]))
}
continue
}
if started {
sb.WriteString(string(seg.runes))
}
}
return truncateRunes(normalizeWhitespace(sb.String()), 100)
}
// contextMatches reports whether a server-extracted context and a client-
// submitted context describe the same anchor. Both are suffixes of the
// same block text when the anchors share a block, so containment in
// either direction verifies; empty or very short contexts never match.
func contextMatches(serverCtx, submitted string) bool {
s := truncateRunes(normalizeWhitespace(submitted), 100)
t := truncateRunes(normalizeWhitespace(serverCtx), 100)
if s == "" || t == "" {
return false
}
short, long := s, t
if len([]rune(short)) > len([]rune(long)) {
short, long = long, short
}
if len([]rune(short)) < 12 {
return false
}
return strings.Contains(long, short)
}
// cssSelectorFor mirrors the readers' selOf: a body-relative
// tag:nth-child(k) chain (k = 1-based position among element siblings).
func cssSelectorFor(block *html.Node) string {
var segs []string
n := block
for n != nil && n.Type == html.ElementNode && n.Data != "body" {
k := 1
sib := n.Parent.FirstChild
for sib != nil && sib != n {
if sib.Type == html.ElementNode {
k++
}
sib = sib.NextSibling
}
segs = append([]string{n.Data + ":nth-child(" + fmt.Sprintf("%d", k) + ")"}, segs...)
n = n.Parent
}
return "body>" + strings.Join(segs, ">")
}
// blockCharOffset computes the offset of (node, runeOff) within the
// concatenated text of its block, in UTF-16 code units — the client-side
// scroll-target currency (JavaScript .length semantics).
func blockCharOffset(block, node *html.Node, runeOff int) int {
segments := collectInlineText(block)
var sb strings.Builder
for _, seg := range segments {
if seg.node == node {
runes := seg.runes
if runeOff < len(runes) {
runes = runes[:runeOff]
}
sb.WriteString(string(runes))
return runeToUTF16Index(sb.String(), utf8.RuneCountInString(sb.String()))
}
sb.WriteString(string(seg.runes))
}
return utf16Len(sb.String())
}
// bookCharOffset computes the book-wide rune offset of (node, runeOff) —
// the reading_progress.character_offset column's currency, consistent with
// TotalCharacters and the percentage derivations.
func bookCharOffset(conv *CFIConverter, spineIndex int, node *html.Node, runeOff int) int {
spine, err := conv.loadSpine()
if err != nil {
return 0
}
before := 0
for i := 0; i < spineIndex && i < len(spine.items); i++ {
doc, _, derr := conv.getContentDoc(i + 1)
if derr != nil {
continue
}
if b := findBody(doc); b != nil {
before += countTextChars(b)
}
}
return before + countTextCharsBefore(node) + runeOff
}
// ProgressAnchor is the full server-computed apply handle for a stored
// standard CFI: the anchor block's cssSelector, the character offset
// within that block's text, and the spine document's href.
type ProgressAnchor struct {
CSSSelector string
CharOffset int
Href string
HealedCFI string
Healed bool
HealedPct *float64
}
// VerifyProgressAnchor resolves a client-submitted standard CFI against
// the EPUB, cross-checks the submitted context text, and heals the anchor
// by text search on any mismatch. charOffset is the anchor's block-
// relative UTF-16 offset (the served char_offset handle); bookOffset is
// the anchor's book-wide rune offset (the character_offset column's
// currency) — callers refresh the column from it on every verified save
// so it never goes stale behind the anchor.
func VerifyProgressAnchor(epubPath, epubcfi, contextText string, percentage float64) (finalCFI string, cssSelector string, anchorHref string, charOffset *int, bookOffset *int, healedPct *float64, healed bool, err error) {
finalCFI = epubcfi
anchorHref = ""
spineIndex, localSteps, err := parseEPUBCFI(epubcfi)
if err != nil {
cfi, sel, href, off, book, pct, healedFlag, herr := healFromContext(epubPath, contextText, percentage)
return cfi, sel, href, off, book, pct, healedFlag, herr
}
conv := cachedConverter(epubPath)
doc, docHref, err := conv.getContentDoc(spineIndex + 1)
if err != nil {
return healFromContext(epubPath, contextText, percentage)
}
node, runeOff, rerr := resolveCFIToNode(doc, localSteps)
if rerr != nil {
return healFromContext(epubPath, contextText, percentage)
}
anchorHref = docHref
block := anchorBlock(node)
serverCtx := blockContextText(block, node, runeOff)
if contextMatches(serverCtx, contextText) {
off := blockCharOffset(block, node, runeOff)
book := bookCharOffset(conv, spineIndex, node, runeOff)
return finalCFI, cssSelectorFor(block), anchorHref, &off, &book, nil, false, nil
}
// Mismatch: heal by text search.
hCFI, hPct, hSel, hHref, hBook, herr := healAnchorByText(epubPath, contextText, percentage, spineIndex)
if herr != nil {
return finalCFI, "", hHref, nil, nil, nil, false, fmt.Errorf("context mismatch (server %q vs client %q) and heal failed: %w",
truncateRunes(serverCtx, 40), truncateRunes(contextText, 40), herr)
}
hOff := blockCharOffsetFor(epubPath, hCFI)
return hCFI, hSel, hHref, &hOff, &hBook, &hPct, true, nil
}
func healFromContext(epubPath, contextText string, percentage float64) (string, string, string, *int, *int, *float64, bool, error) {
cfi, healedPct, sel, href, book, err := healAnchorByText(epubPath, contextText, percentage, -1)
if err != nil {
return "", "", "", nil, nil, nil, false, err
}
off := blockCharOffsetFor(epubPath, cfi)
return cfi, sel, href, &off, &book, &healedPct, true, nil
}
func healAnchorByText(epubPath, contextText string, percentage float64, spineIndex int) (string, float64, string, string, int, error) {
if epubPath == "" {
return "", 0, "", "", 0, fmt.Errorf("no epub available for text anchoring")
}
conv := cachedConverter(epubPath)
spine, err := conv.loadSpine()
if err != nil {
return "", 0, "", "", 0, err
}
needle := truncateRunes(normalizeWhitespace(contextText), 40)
if len([]rune(needle)) < 12 {
return "", 0, "", "", 0, fmt.Errorf("context too short to anchor")
}
type match struct {
spine int
node *html.Node
off int
}
var matches []match
totalChars := 0
charsBefore := make([]int, len(spine.items))
for i := range spine.items {
doc, _, derr := conv.getContentDoc(i + 1)
if derr != nil {
continue
}
body := findBody(doc)
if body == nil {
continue
}
charsBefore[i] = totalChars
totalChars += countTextChars(body)
if n, runeOff := findTextInNode(body, needle); n != nil {
matches = append(matches, match{spine: i, node: n, off: runeOff})
}
}
if len(matches) == 0 || totalChars <= 0 {
return "", 0, "", "", 0, fmt.Errorf("context not found in book")
}
best := matches[0]
bestDist := -1.0
for _, m := range matches {
frac := (float64(charsBefore[m.spine]) + float64(countTextCharsBefore(m.node)+m.off)) / float64(totalChars)
d := frac - percentage
if d < 0 {
d = -d
}
if bestDist < 0 || d < bestDist {
best = m
bestDist = d
}
}
bookOff := charsBefore[best.spine] + countTextCharsBefore(best.node) + best.off
healedPct := float64(bookOff) / float64(totalChars)
cfi, err := buildCFI(best.spine, best.node, best.off)
if err != nil {
return "", 0, "", "", 0, err
}
sel := ""
if block := anchorBlock(best.node); block != nil {
sel = cssSelectorFor(block)
}
return cfi, healedPct, sel, spine.items[best.spine].href, bookOff, nil
}
func blockCharOffsetFor(epubPath, cfi string) int {
n, off, _, herr := cfiTextAtAnchorInternal(epubPath, cfi)
if herr != nil {
return 0
}
block := anchorBlock(n)
if block == nil {
return 0
}
return blockCharOffset(block, n, off)
}
// cfiTextAtAnchorInternal resolves a stored standard CFI to its anchor
// text node, rune offset, and containing document.
func cfiTextAtAnchorInternal(epubPath, cfi string) (*html.Node, int, *html.Node, error) {
spineIndex, localSteps, err := parseEPUBCFI(cfi)
if err != nil {
return nil, 0, nil, err
}
conv := cachedConverter(epubPath)
doc, _, err := conv.getContentDoc(spineIndex + 1)
if err != nil {
return nil, 0, nil, err
}
node, off, err := resolveCFIToNode(doc, localSteps)
return node, off, doc, err
}
// ProgressCSSSelector derives the anchor block's cssSelector from a stored
// standard CFI.
func ProgressCSSSelector(epubPath, epubcfi string) (string, error) {
spineIndex, localSteps, err := parseEPUBCFI(epubcfi)
if err != nil {
return "", err
}
conv := cachedConverter(epubPath)
doc, _, err := conv.getContentDoc(spineIndex + 1)
if err != nil {
return "", err
}
node, _, err := resolveCFIToNode(doc, localSteps)
if err != nil {
return "", err
}
block := anchorBlock(node)
if block == nil {
return "", fmt.Errorf("no block ancestor for anchor")
}
return cssSelectorFor(block), nil
}
+22
View File
@@ -0,0 +1,22 @@
package sync
import (
"os"
"testing"
)
func TestManualRealBookAnchor(t *testing.T) {
path := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (1269)/1984 - George Orwell.epub"
if _, err := os.Stat(path); err != nil {
t.Skip("real book not present on this machine")
}
cfi := "epubcfi(/6/2!/4[x1984]/8[_idContainer003]/62/1:456)"
ctx := "was at war with one of these Powers it was generally at peace with the other"
finalCFI, sel, href, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, cfi, ctx, 0.0415)
if err != nil {
t.Fatalf("VerifyProgressAnchor error: %v", err)
}
t.Logf("final=%q\n healed=%v healedPct=%v\n selector=%q href=%q charOff=%v", finalCFI, healed, healedPct, sel, href, charOff)
sel2, err2 := ProgressCSSSelector(path, cfi)
t.Logf("ProgressCSSSelector: %q err=%v", sel2, err2)
}
+248
View File
@@ -0,0 +1,248 @@
package sync
import (
"fmt"
"testing"
)
// The fixture (writeTestEPUB in cfi_converter_test.go): 6 spine docs.
// doc2 = spine index 1 with two paragraphs:
// p1: "The family of Dashwood had long been settled in Sussex."
// p2: "Their estate was large, and their residence was at Norland Park."
// Hand-derived local paths (html→body /4, body→div /4, div→p /4|/6,
// p→text chunk /1):
const (
dashwoodCFI = "epubcfi(/6/4!/4/2/2/1:0)"
dashwoodText = "The family of Dashwood had long been settled in Sussex."
estateCFI = "epubcfi(/6/4!/4/2/4/1:0)"
estateText = "Their estate was large, and their residence was at Norland Park."
dashwoodSelect = "body>div:nth-child(1)>p:nth-child(1)"
estateSelect = "body>div:nth-child(1)>p:nth-child(2)"
)
func TestVerifyProgressAnchorAcceptsExact(t *testing.T) {
path := writeTestEPUB(t)
finalCFI, sel, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, dashwoodCFI, dashwoodText, 0.3)
_ = charOff
if err != nil {
t.Fatalf("verify error: %v", err)
}
if healed {
t.Errorf("exact anchor should not heal")
}
if healedPct != nil {
t.Errorf("exact anchor should not carry a healed percentage")
}
if finalCFI != dashwoodCFI {
t.Errorf("finalCFI = %q, want unchanged %q", finalCFI, dashwoodCFI)
}
if sel != dashwoodSelect {
t.Errorf("cssSelector = %q, want %q", sel, dashwoodSelect)
}
}
func TestVerifyProgressAnchorHealsMismatch(t *testing.T) {
path := writeTestEPUB(t)
// Anchored at p1 but the context is p2's text: the classic
// client-projection bug — the server must heal to the true location.
finalCFI, _, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, dashwoodCFI, estateText, 0.3)
_ = charOff
if err != nil {
t.Fatalf("verify error: %v", err)
}
if !healed {
t.Fatalf("expected healing, got none (cfi=%q)", finalCFI)
}
if finalCFI != estateCFI {
t.Errorf("healed CFI = %q, want %q", finalCFI, estateCFI)
}
if healedPct == nil || *healedPct <= 0 {
t.Errorf("healed percentage not recomputed: %v", healedPct)
}
// Healing must converge: re-verifying the healed anchor is a no-op.
finalCFI2, _, _, _, _, healedPct2, healed2, err := VerifyProgressAnchor(path, finalCFI, estateText, 0.3)
if err != nil {
t.Fatalf("re-verify error: %v", err)
}
if healed2 {
t.Errorf("healed anchor should be stable, got healed again to %q (pct %v)", finalCFI2, healedPct2)
}
if finalCFI2 != finalCFI {
t.Errorf("re-verify CFI = %q, want %q", finalCFI2, finalCFI)
}
}
func TestVerifyProgressAnchorHealsUnresolvable(t *testing.T) {
path := writeTestEPUB(t)
// Element index 99 is out of range in doc2 — the anchor cannot resolve.
bogus := "epubcfi(/6/4!/4/2/99/1:0)"
finalCFI, _, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, bogus, dashwoodText, 0.05)
_ = charOff
if err != nil {
t.Fatalf("verify error: %v", err)
}
if !healed {
t.Fatal("unresolvable anchor should heal from context")
}
if finalCFI != dashwoodCFI {
t.Errorf("healed CFI = %q, want the Dashwood anchor %q", finalCFI, dashwoodCFI)
}
if healedPct == nil {
t.Errorf("healed percentage not recomputed")
}
}
func TestVerifyProgressAnchorDumbClient(t *testing.T) {
path := writeTestEPUB(t)
// No CFI at all — a client that only knows percentage + context is
// fully supported: the server anchors structurally from the text.
finalCFI, sel, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, "", estateText, 0.3)
_ = charOff
if err != nil {
t.Fatalf("verify error: %v", err)
}
if !healed {
t.Fatal("context-only submission should count as anchored-by-heal")
}
if finalCFI != estateCFI {
t.Errorf("anchored CFI = %q, want %q", finalCFI, estateCFI)
}
if sel != estateSelect {
t.Errorf("cssSelector = %q, want %q", sel, estateSelect)
}
if healedPct == nil {
t.Errorf("percentage not recomputed for context-only anchor")
}
}
func TestContextMatches(t *testing.T) {
block := "The family of Dashwood had long been settled in Sussex."
cases := []struct {
name string
serverCtx string
submitted string
want bool
}{
{"exact", block, block, true},
{"suffix of block", block, "settled in Sussex.", true},
{"block is suffix", "settled in Sussex.", block, true},
{"mid-block substring", block, "Dashwood had long been", true},
{"different text", block, "completely unrelated words here", false},
{"too short", block, "the", false},
{"empty submitted", block, "", false},
{"empty server", "", "some long enough context text", false},
}
for _, tc := range cases {
if got := contextMatches(tc.serverCtx, tc.submitted); got != tc.want {
t.Errorf("contextMatches(%q, %q) = %v, want %v", tc.serverCtx, tc.submitted, got, tc.want)
}
}
}
func TestParseStandardCFIRange(t *testing.T) {
// Web progress CFIs are range CFIs; parse must resolve to the start arm.
spineIndex, localSteps, err := parseEPUBCFI("epubcfi(/6/4!/4/4:0,/4/4:53)")
if err != nil {
t.Fatalf("parse error: %v", err)
}
if spineIndex != 1 {
t.Errorf("spineIndex = %d, want 1", spineIndex)
}
if len(localSteps) != 4 {
t.Fatalf("steps = %d, want 4 (parent + start arm)", len(localSteps))
}
last := localSteps[len(localSteps)-1]
if last.Index != 4 || !last.HasOffset || last.Offset != 53 {
t.Errorf("start-arm step = %+v, want /4:0", last)
}
}
// The emoji in fixture doc2's third paragraph are astral plane runes:
// one rune, two UTF-16 code units. Every wire offset (CFI terminals,
// the served char_offset) must therefore be UTF-16, while the book-wide
// character_offset column stays rune-based.
//
// Case 1: the context starts at rune 31 of the paragraph text ("The
// family crest shows a globe " = 31 BMP runes), so its UTF-16 offset is
// also 31 — the currencies agree.
//
// Case 2: the context starts at rune 33 ("and a rocket ..."), with the
// astral 🌍 (rune 31, units 31-32) BEFORE the offset — the UTF-16 offset
// is 34, one more than the rune offset. That +1 is the whole point of
// the boundary conversion.
//
// Local path: p3 of doc2's div = /4/2/6, text chunk /1.
func TestAstralOffsetCurrency(t *testing.T) {
path := writeTestEPUB(t)
cases := []struct {
name string
context string
wantTerm int
}{
{"before any emoji", "🌍 and a rocket 🚀 flying onward.", 31},
{"after one emoji", "and a rocket 🚀 flying onward.", 34},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
wantCFI := fmt.Sprintf("epubcfi(/6/4!/4/2/6/1:%d)", tc.wantTerm)
finalCFI, sel, _, charOff, bookOff, _, healed, err := VerifyProgressAnchor(path, "", tc.context, 0.3)
if err != nil {
t.Fatalf("heal error: %v", err)
}
if !healed {
t.Fatalf("expected heal from context-only submission")
}
if finalCFI != wantCFI {
t.Errorf("healed CFI = %q, want %q (UTF-16 terminal)", finalCFI, wantCFI)
}
if sel != "body>div:nth-child(1)>p:nth-child(3)" {
t.Errorf("cssSelector = %q", sel)
}
if charOff == nil || *charOff != tc.wantTerm {
t.Errorf("block char_offset = %v, want %d UTF-16 units", charOff, tc.wantTerm)
}
if bookOff == nil || *bookOff <= 0 {
t.Errorf("book offset = %v, want a positive book-wide rune offset", bookOff)
}
// Round trip: the healed CFI must verify exactly, with the
// same UTF-16 block handle and a stable book offset.
finalCFI2, _, _, charOff2, bookOff2, healedPct2, healed2, err := VerifyProgressAnchor(path, finalCFI, tc.context, 0.3)
if err != nil {
t.Fatalf("re-verify error: %v", err)
}
if healed2 || healedPct2 != nil {
t.Errorf("exact round trip should not heal (healed=%v)", healed2)
}
if finalCFI2 != wantCFI {
t.Errorf("re-verified CFI = %q, want %q", finalCFI2, wantCFI)
}
if charOff2 == nil || *charOff2 != tc.wantTerm {
t.Errorf("re-verified block char_offset = %v, want %d", charOff2, tc.wantTerm)
}
if bookOff2 == nil || bookOff == nil || *bookOff2 != *bookOff {
t.Errorf("book offset unstable: %v vs %v", bookOff2, bookOff)
}
})
}
}
// The book-wide offset must order anchors the way the book orders them:
// a later paragraph in the same document has a strictly larger
// character_offset.
func TestBookOffsetOrdering(t *testing.T) {
path := writeTestEPUB(t)
_, _, _, _, book1, _, _, err1 := VerifyProgressAnchor(path, dashwoodCFI, dashwoodText, 0.3)
_, _, _, _, book2, _, _, err2 := VerifyProgressAnchor(path, estateCFI, estateText, 0.3)
if err1 != nil || err2 != nil {
t.Fatalf("verify errors: %v %v", err1, err2)
}
if book1 == nil || book2 == nil {
t.Fatalf("book offsets missing: %v %v", book1, book2)
}
if *book2 <= *book1 {
t.Errorf("estate offset %d should exceed dashwood offset %d", *book2, *book1)
}
}
-4
View File
@@ -30,9 +30,7 @@ var mimeTypes = map[string]string{
".fb2": "application/x-fictionbook+xml",
".txt": "text/plain",
".rtf": "application/rtf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".lit": "application/x-ms-reader",
".pdb": "application/vnd.palm",
".prc": "application/vnd.palm",
}
@@ -46,9 +44,7 @@ var ReflowableFormats = map[string]bool{
".fb2": true,
".txt": true,
".rtf": true,
".doc": true,
".docx": true,
".lit": true,
".pdb": true,
".prc": true,
}
+32
View File
@@ -125,6 +125,38 @@ func ConvertToCanonical(
}
}
// ConvertHighlightRangeToCanonical is the highlight-specific twin of
// ConvertToCanonical: it anchors a device highlight's pos0/pos1 pair by the
// selection text so both endpoints derive from one document match — same
// spine, ordered — instead of two independent structural landings. Returns
// a "fallback" locator with an empty CFI when the text cannot be located;
// callers fall back to ConvertToCanonical per endpoint.
func ConvertHighlightRangeToCanonical(
epubPath string,
formatGroup string,
pos0 string,
pos1 string,
contextText string,
percentage float64,
) CanonicalLocator {
if !isConvertible(formatGroup) || epubPath == "" {
return CanonicalLocator{CFI: pos0, Precision: "passthrough", Percentage: percentage}
}
if !IsCREXPointer(pos0) {
return CanonicalLocator{CFI: pos0, Precision: "already-standard", Percentage: percentage}
}
result := cachedConverter(epubPath).ConvertHighlightRange(pos0, pos1, contextText, percentage)
if result == nil || result.EPUBCFI == "" {
return CanonicalLocator{CFI: "", Precision: "fallback", Percentage: percentage}
}
return CanonicalLocator{
CFI: result.EPUBCFI,
EndCFI: result.EndEPUBCFI,
Precision: result.Precision,
Percentage: result.Percentage,
}
}
func ConvertFromCanonical(
source LocatorSource,
canonicalCFI string,
+77 -4
View File
@@ -274,17 +274,27 @@ func EstimatedPages(totalCharacters int64) int {
type ProgressService struct {
db *database.Queries
connManager *ConnectionManager
mediaPaths MediaPathResolver
}
// MediaPathResolver resolves a media item's library-relative file path to
// an absolute path, so the progress service can verify submitted anchors
// against the book itself.
type MediaPathResolver interface {
ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error)
}
func (s *ProgressService) SetMediaPathResolver(r MediaPathResolver) { s.mediaPaths = r }
func NewProgressService(db *database.Queries, connManager *ConnectionManager) *ProgressService {
return &ProgressService{db: db, connManager: connManager}
}
type SaveProgressRequest struct {
MediaItemID pgtype.UUID
UserID pgtype.UUID
Source string
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
Source string
DeviceID pgtype.UUID
Percentage *float64
Epubcfi *string
@@ -305,6 +315,16 @@ type SaveProgressRequest struct {
DeviceType string
DeviceName string
Broadcast bool
// ClearLocator declares that this write INTENDS to carry no position
// locator: percentage-only writers (devices whose converter declined,
// Kobo markup percentage syncs, queue entries without CFI). Without it,
// seeding below would re-pair the new percentage with the PREVIOUS
// writer's stale CFI/context — the incoherent row shape that feeds the
// client-side far-anchor restore floor. Locater-less rows are honest:
// percentage restores are approximate, and the next anchor-bearing
// write re-establishes the pair. Request-supplied fields always win.
ClearLocator bool
}
func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequest) (database.ReadingProgress, error) {
@@ -397,6 +417,22 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
params.ReadingMode = pgtype.Text{String: *req.ReadingMode, Valid: true}
}
// Locator coherence: a write that declares no locator must not
// silently re-pair its new percentage with the previous writer's
// stale CFI/context (inherited by the seeding above). Clear them so
// the row is an honest percentage-only position. Request-carried
// fields always win — and a request-carried context survives so the
// verify/heal pass below can mint a fresh anchor from it.
if req.ClearLocator && req.Epubcfi == nil {
params.Epubcfi = pgtype.Text{}
if req.ContextText == nil {
params.ContextText = pgtype.Text{}
}
if req.CharacterOffset == nil {
params.CharacterOffset = pgtype.Int8{}
}
}
params.LastSyncDevice = pgtype.Text{String: req.Source, Valid: true}
params.LastSyncSource = pgtype.Text{String: req.Source, Valid: true}
@@ -435,6 +471,43 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
}
}
// Canonical position authority: resolve the submitted CFI against the
// book itself, cross-check the submitted context text, and heal the
// anchor by text search on any mismatch. Clients submit what their
// renderer can reliably observe (percentage + visible text); the
// server owns the structural math and keeps buggy clients from
// poisoning stored positions.
if !isFixed && s.mediaPaths != nil && params.ContextText.Valid &&
isConvertible(string(formatGroup)) && formatGroup != "" {
if path, perr := s.mediaPaths.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); perr == nil && path != "" {
submittedCFI := ""
if params.Epubcfi.Valid {
submittedCFI = params.Epubcfi.String
}
submittedPct := 0.0
if params.Percentage.Valid {
submittedPct = params.Percentage.Float64
}
if finalCFI, _, _, _, bookOff, healedPct, healed, verr := VerifyProgressAnchor(path, submittedCFI, params.ContextText.String, submittedPct); verr != nil {
log.Printf("Bookhoard: progress anchor verify failed for %s: %v", req.MediaItemID.String(), verr)
} else {
if healed || (!params.Epubcfi.Valid && finalCFI != "") {
params.Epubcfi = pgtype.Text{String: finalCFI, Valid: true}
if healedPct != nil {
params.Percentage = pgtype.Float8{Float64: *healedPct, Valid: true}
}
}
// The verified book-wide offset refreshes the column on
// EVERY verified save — not only heals — so it never goes
// stale behind the anchor (and a block-relative offset is
// never written into the book-level column).
if bookOff != nil {
params.CharacterOffset = pgtype.Int8{Int64: int64(*bookOff), Valid: true}
}
}
}
}
conflictDetected := false
if hasExisting && existing.LastSyncSource.Valid && existing.LastSyncSource.String != req.Source {
if existing.LastSyncTimestamp.Valid {
+22 -18
View File
@@ -126,29 +126,29 @@ func (p *SyncQueueProcessor) EnqueueProgress(update *ProgressUpdate) error {
}
type HighlightUpdate struct {
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
SelectionText string
StartPosition string
EndPosition string
Color string
NoteText string
EpubcfiStart string
EpubcfiEnd string
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
SelectionText string
StartPosition string
EndPosition string
Color string
NoteText string
EpubcfiStart string
EpubcfiEnd string
PercentageStart float64
PercentageEnd float64
Source string
DeviceSyncData map[string]interface{}
Source string
DeviceSyncData map[string]interface{}
}
type NoteUpdate struct {
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
Content string
Position string
Source string
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
Content string
Position string
Source string
DeviceSyncData map[string]interface{}
}
@@ -443,6 +443,10 @@ func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUI
if v, ok := syncData["epubcfi"].(string); ok {
req.Epubcfi = &v
}
// Entries without a CFI are percentage-only by intent: declare it
// so the service clears any inherited stale anchor instead of
// re-pairing it with this percentage.
req.ClearLocator = req.Epubcfi == nil
if v, ok := syncData["context_text"].(string); ok {
req.ContextText = &v
}
+31
View File
@@ -0,0 +1,31 @@
package utils
import (
"time"
)
// UserLocation resolves an IANA timezone name to a *time.Location, falling
// back to UTC for empty or invalid values. The single resolution point for
// server-side display conversion: the server owns timezone rendering, so
// clients receive display-ready strings (or tz-aware instants) and never
// convert themselves.
func UserLocation(timezone string) *time.Location {
if timezone == "" {
return time.UTC
}
loc, err := time.LoadLocation(timezone)
if err != nil || loc == nil {
return time.UTC
}
return loc
}
// FormatTimeInTimezone renders t in the given IANA timezone as
// MM-DD-YYYY HH:MM AM/PM (the established display format across the
// templates). Zero times render empty.
func FormatTimeInTimezone(t time.Time, timezone string) string {
if t.IsZero() {
return ""
}
return t.In(UserLocation(timezone)).Format("01-02-2006 03:04 PM")
}
+10 -2
View File
@@ -12,16 +12,24 @@
"dev": "npm run build:ts:dev && npm run build:css"
},
"dependencies": {
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#e16530a",
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#7796b05",
"alpinejs": "^3.15.8",
"chart.js": "^4.5.1",
"dompurify": "^3.4.16",
"fflate": "^0.8.3",
"highlight.js": "^11.11.1",
"htmx.org": "^2.0.8",
"lunr": "^2.3.9"
"lunr": "^2.3.9",
"markdown-it": "^15.0.2",
"markdown-it-footnote": "^4.0.0",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@types/markdown-it": "^14.2.0",
"@types/turndown": "^5.0.6",
"autoprefixer": "^10.4.27",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Build a synthetic 'torture test' EPUB exercising every CRE-xpointer -> EPUB-CFI
conversion hazard the server converter can encounter.
Each chapter targets one hazard class; constructs carry comments naming the
hazard. Output: uploads/Ebooks/Bookhoard Test/CRE CFI Torture Test/CRE CFI Torture Test.epub
(or argv[1]). Deterministic output for reproducible tests.
"""
import os
import sys
import zipfile
MIMETYPE = "application/epub+zip"
CONTAINER = """<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
"""
def page(name, title, body):
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head><title>{title}</title><link rel="stylesheet" type="text/css" href="../css/style.css"/></head>
<body>
<!-- chapter: {name} -->
<h1 id="{name}-h">{title}</h1>
{body}
</body>
</html>
"""
CHAPTERS = []
# 1. The core bug shape: multi-text-node paragraphs (inline markup splitting text)
CHAPTERS.append(("split-text", "Split Text Nodes", """
<p id="st-p1">In the beginning <span class="verse">1</span> God created the heaven and the earth <span class="verse">2</span> and the earth was without form.</p>
<p id="st-p2">A <em>single</em> sentence with <strong>several</strong> inline <span class="mark">marked</span> spans inside it.</p>
<p id="st-p3">First text node here.<span class="cont">Immediately-following span text.</span>Text that continues after the span.<span class="cont2">And a second span.</span>Tail text node.</p>
<p id="st-p4">Quoted "double" and 'single' marks, an ampersand &amp; entity, and a unicode em—dash.</p>
<div class="group" id="st-group">
<p id="st-p5">Nested group paragraph one <span class="verse">1</span> with more text after the marker.</p>
<p id="st-p6">Nested group paragraph two, plain text only.</p>
</div>
"""))
# 2. Whitespace-only text nodes + comments between elements (sibling-index drift)
CHAPTERS.append(("whitespace-comments", "Whitespace and Comments", """
<ul id="wc-list">
<!-- comment directly inside list -->
<li id="wc-li1">
<p id="wc-p1">Indented markup with whitespace text nodes on every side.</p>
<!-- comment between siblings -->
<p id="wc-p2">Second indented paragraph with irregular internal spacing.</p>
</li>
<!-- another comment -->
<li id="wc-li2">
<p id="wc-p3">Paragraph after multiple comment and whitespace siblings.</p>
</li>
</ul>
<p id="wc-p4">Tail paragraph after the list.</p>
"""))
# 3. Drop caps + footnote anchors (single-char inline splits)
CHAPTERS.append(("dropcaps-footnotes", "Dropcaps and Footnotes", """
<p id="df-p1"><span class="dropcap">O</span>nce upon a time a drop cap split the first word from itself.</p>
<p id="df-p2">A claim requiring support<span class="fnref"><a id="fnref-1" href="#fn-1">1</a></span> and a second claim<span class="fnref"><a id="fnref-2" href="#fn-2">2</a></span> in the same paragraph.</p>
<p id="df-p3">Multiple <a href="#df-p1">internal links</a> interleaved <a href="#df-p2">through text</a> as anchors.</p>
<div class="footnotes" id="df-notes">
<p id="df-fn1"><a id="fn-1" href="#fnref-1">1.</a> The first footnote, body text follows the backlink.</p>
<p id="df-fn2"><a id="fn-2" href="#fnref-2">2.</a> The second footnote with <em>emphasis</em> inside.</p>
</div>
"""))
# 4. Poetry / drama: br-separated lines, speaker spans, line numbers
CHAPTERS.append(("poetry", "Poetry and Drama", """
<p id="po-dramap"><span class="speaker">HAMLET.</span> To be, or not to be, that is the question:<br/>Whether 'tis nobler in the mind to suffer<br/>The slings and arrows of outrageous fortune.</p>
<p id="po-verse1"><span class="line"><span class="lnum">1</span>The curfew tolls the knell of parting day,</span><br/><span class="line"><span class="lnum">2</span>The lowing herd wind slowly o'er the lea,</span><br/><span class="line"><span class="lnum">3</span>The plowman homeward plods his weary way,</span><br/><span class="line"><span class="lnum">4</span>And leaves the world to darkness and to me.</span></p>
"""))
# 5. Deep nesting: quotes in quotes, lists in lists, tables with inline markup
CHAPTERS.append(("nested", "Nested Structures", """
<blockquote id="ne-q1"><p id="ne-q1p1">Outer quotation text begins here <span class="verse">1</span> and continues.</p>
<blockquote id="ne-q2"><p id="ne-q2p1">Inner quotation, one level deeper <em>with emphasis</em> mid-sentence.</p></blockquote>
<p id="ne-q1p2">Outer quotation resumes after the inner block.</p>
</blockquote>
<ul id="ne-list1">
<li id="ne-li1">Level one item
<ul id="ne-list2">
<li id="ne-li2">Level two item
<ul id="ne-list3">
<li id="ne-li3">Level three item with <strong>bold</strong> text.</li>
<li id="ne-li4">Level three sibling item.</li>
</ul>
</li>
</ul>
</li>
<li id="ne-li5">Level one sibling item.</li>
</ul>
<table id="ne-table">
<tr id="ne-tr1"><td id="ne-td1">Cell with <em>inline</em> markup</td><td id="ne-td2">Second cell, plain</td></tr>
<tr id="ne-tr2"><td id="ne-td3">Cell three with a <span class="verse">7</span> marker</td><td id="ne-td4">Cell four</td></tr>
</table>
<dl id="ne-dl"><dt id="ne-dt1">Term one</dt><dd id="ne-dd1">Definition one with text.</dd><dt id="ne-dt2">Term two</dt><dd id="ne-dd2">Definition two with text.</dd></dl>
"""))
# 6. Unicode hazards: surrogate pairs, combining marks, RTL, invisible chars, entities
CHAPTERS.append(("unicode", "Unicode Hazards", """
<p id="un-emoji">Emoji with surrogate pairs: 🎉🚀 and a ZWJ family 👨‍👩‍👧‍👦 plus math 𝛑𝕏 and flags 🇺🇸🇯🇵.</p>
<p id="un-hebrew">Hebrew with niqqud: בְּרֵאשִׁית בָּרָא אֱלֹהִים and a Midrash on דָּבָר מִשְׁפָּט אֱמֶת.</p>
<p id="un-arabic">Arabic RTL with embedded LTR: يبدأ النص العربي هنا ثم 42 ثم ينتهي، مع تشكيل الكلمات المُعرَبة.</p>
<p id="un-combining">Decomposed e + combining acute: é vs precomposed é, and ĉ vs ĉ with combining circumflex.</p>
<p id="un-invisible">Soft hyphen in­side a word, zero&#8201;thin space, zero-width ​space, and trailing no-break space&#160;here.</p>
<p id="un-entities">Ampersand &amp;amp;, less &amp;lt;, greater &amp;gt;, apostrophe &amp;#39;, quote &amp;quot;, all rendered literally.</p>
"""))
# 7. Ruby / CJK
CHAPTERS.append(("ruby", "Ruby Annotations", """
<p id="ru-p1"><ruby>漢字<rt>かんじ</rt></ruby>の<ruby>読<rt>よ</rt></ruby>み<span>方</span>をテストする。</p>
<p id="ru-p2">日本語のテキストには<ruby>難<rt>むずか</rt></ruby>しい<ruby>漢字<rt>かんじ</rt></ruby>がたくさんあります。</p>
<p id="ru-p3">The ruby base 漢 and its annotation かん are separate text nodes in raw XHTML.</p>
"""))
# 8. Giant chapter with heavy phrase repetition (text-search disambiguation)
giant = ['<p id="gi-p0">Opening paragraph of the giant chapter, unique opening sentence.</p>']
for i in range(1, 401):
giant.append(
f'<p id="gi-p{i}">Verse <span class="verse">{i}</span> And it came to pass in those days that a repeated phrase appeared again among the people. '
f'Variation marker alpha-{i}: the sentence continues with unique tail number {i * 7} before ending.</p>'
)
CHAPTERS.append(("giant", "Giant Repeated Chapter", "\n".join(giant)))
# 9. Edge constructs: empties, same-tag adjacency, span-first paragraphs, deep same-tag nesting
deep = "<p id=\"ed-deep\">Deep nesting bottom text.</p>"
for lvl in range(10, 0, -1):
deep = f'<div id="ed-lvl{lvl}">{deep}</div>'
CHAPTERS.append(("edge", "Edge Constructs", f"""
<p id="ed-spanfirst"><span class="lead">Leading span before any direct text.</span> Direct text follows the span.<span class="tail">Tail span.</span></p>
<p id="ed-adjacent"><span class="a">Adjacent</span><span class="b">same-tag</span><span class="c">spans</span> then plain text.</p>
<p id="ed-empty">Text with an empty span <span></span> mid-sentence and an empty emphasis <em></em> after it.</p>
<p id="ed-emptyp"></p>
<div id="ed-onlychild">{deep}</div>
<p id="ed-attrs" data-why="not">Paragraph with odd attribute values like &quot;quotes&quot; and &apos;apostrophes&apos;.</p>
"""))
CSS = """body { margin: 1em; }
.verse, .lnum, .fnref { font-size: 0.7em; vertical-align: super; color: #666; }
.speaker { font-variant: small-caps; }
.dropcap { font-size: 2.5em; font-weight: bold; float: left; line-height: 1; }
.line { display: block; margin-left: 2em; }
blockquote { margin: 1em 2em; border-left: 2px solid #ccc; padding-left: 1em; }
ruby rt { font-size: 0.5em; }
"""
def build(out_path):
files = {}
files["mimetype"] = MIMETYPE
files["META-INF/container.xml"] = CONTAINER
files["css/style.css"] = CSS
manifests, spines = [], []
for idx, (name, title, body) in enumerate(CHAPTERS, start=1):
files[f"OPS/chapters/{name}.xhtml"] = page(name, title, body)
manifests.append(f' <item id="ch{idx}" href="chapters/{name}.xhtml" media-type="application/xhtml+xml"/>')
spines.append(f' <itemref idref="ch{idx}"/>')
nav_items = "\n".join(
f' <li><a href="chapters/{name}.xhtml">{title}</a></li>'
for name, title, _ in CHAPTERS
)
files["OPS/nav.xhtml"] = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Navigation</title></head>
<body>
<nav epub:type="toc" id="toc"><h1>Contents</h1>
<ol>
{nav_items}
</ol>
</nav>
</body>
</html>
"""
nav_manifest = ' <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>'
files["OPS/content.opf"] = f"""<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">urn:uuid:1f2e3d4c-5b6a-7948-b7c6-d5e4f3a2b1c0</dc:identifier>
<dc:title>CRE CFI Torture Test</dc:title>
<dc:creator>Bookhoard Test</dc:creator>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2026-10-12T00:00:00Z</meta>
</metadata>
<manifest>
{nav_manifest}
{chr(10).join(manifests)}
</manifest>
<spine>
{chr(10).join(spines)}
</spine>
</package>
"""
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zipfile.ZipFile(out_path, "w") as z:
z.writestr(zipfile.ZipInfo("mimetype"), MIMETYPE, compress_type=zipfile.ZIP_STORED)
for arcname in sorted(files):
if arcname == "mimetype":
continue
z.writestr(arcname, files[arcname], compress_type=zipfile.ZIP_DEFLATED)
print(f"wrote {out_path} ({os.path.getsize(out_path):,} bytes, {len(CHAPTERS)} chapters)")
if __name__ == "__main__":
out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
os.path.dirname(__file__), "..",
"uploads/Ebooks/Bookhoard Test/CRE CFI Torture Test/CRE CFI Torture Test.epub")
build(os.path.normpath(out))
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more