fix(shared): match reader file extensions case-insensitively - #508

Merged
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21
Jul 27, 2026
Merged

fix(shared): match reader file extensions case-insensitively#508
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21

Conversation

@mbret

Copy link
Copy Markdown
Owner

The bug

isFileSupported (packages/shared/src/contentType.ts) builds the extension from the raw file name and compares it against the all-lowercase READER_ACCEPTED_EXTENSIONS list. Any file with an uppercase or mixed-case extension is reported as unsupported:

  • isFileSupported({ name: "MyBook.EPUB" })false (verified by executing the real code; "MyBook.epub"true)

How to observe it

isFileSupported is the gate for which files become books:

  • apps/api/src/plugins/synology-drive/client.ts:92 and apps/api/src/plugins/webdav/operations.ts:48 filter sync items with it (name only, no mime type available), so Book.EPUB / comic.CBZ on a NAS or WebDAV share is silently skipped during sync and never appears in the library.
  • apps/web/src/common/FileTreeView/LazyTreeView.tsx:195, apps/web/src/plugins/webdav/upload/FileBrowseStep.tsx:25, and apps/web/src/plugins/synology-drive/browsing/tree.ts:16 grey out / hide those files in the pickers.

Case-insensitivity is the established contract elsewhere: the sibling isPotentialZipFile in the same file lowercases the name first, and the archive reader's own getExtension lowercases too.

The fix

Lowercase the extracted extension in isFileSupported before comparing. getUrlExtension itself is left untouched (its casing is preserved for other consumers).

Verification

  • New regression test packages/shared/src/contentType.test.ts: MyBook.EPUB / comic.CBZ / document.Pdf fail on the previous code and pass after the fix; lowercase, mime-type, and unsupported-file cases covered.
  • npm run lint, npm run build (all 7 projects), shared suite 102/102 pass.
  • npm run test at repo root: only pre-existing failures remain — the same 15 tests in apps/web/src/http/HttpClientApi.web.test.ts + httpClientApi.sw.test.ts fail identically on a clean develop checkout (re-verified with this change stashed).

Other findings (not addressed in this PR)

Confirmed by tracing the real code paths; listed so they aren't lost:

  • bulkDelete omits _rev, so dangling-link deletion silently never deletes (apps/api/src/lib/couch/bulkDelete.ts): it posts {_id, _deleted: true} without _rev to _bulk_docs; CouchDB answers each doc with a conflict error (nano's bulk doesn't throw on per-doc errors), so deleteDanglingLinks records the link as deleted in the sync report while the doc survives. Dangling links accumulate and are "re-deleted" on every sync. The caller has _rev in hand (Helpers["find"] returns it) — the fix is to forward it.
  • useCollections' isNotInterested: "only" branch intersects the wrong array (apps/web/src/collections/useCollections.ts:152): it uses bookIds (the query param, undefined for the search screen caller) instead of the notInterestedBookIds computed just above — and intersection(collection.books, undefined) degenerates to the full book list. Setting the search filter "Show not interested contents: Only" therefore shows every non-empty collection instead of only collections with a not-interested book.
  • Empty Google metadata guard is always false (apps/api/src/lib/metadata/google/getGoogleBookMetadata.ts:98): if (!Object.keys(parsedMetadata)) return undefined — an array is always truthy (missing .length), so a no-result lookup still emits a field-less { type: "googleBookApi" } source entry onto the book.
  • createThrottler never clears its setInterval (apps/api/src/lib/utils.ts:97): each dropbox/google/one-drive sync spawns a 50 ms interval that lives for the whole process lifetime — a slow timer/CPU leak on a long-running server.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ


Generated by Claude Code

isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
obokuReadyReadyPreview, CommentJul 21, 2026 12:28pm

@mbret
mbret merged commit ba9dcda into developJul 27, 2026
4 checks passed
@mbret
mbret deleted the fix/bug-hunt-2026-07-21 branch July 27, 2026 12:18
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 5, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(api): consolidate synology-drive link session resolution (#526)
getFileMetadata, getFolderMetadata and download each repeated the same
~18-line preamble: validate connectorId/credentials/db, load the
connector, open a Synology Drive session and read fileId from the link.
Extract it into a single openSynologyDriveSessionForLink helper so the
validation and error handling live in one place.
Claude-Session: https://claude.ai/code/session_01SiouS4DW4vVoe9wfNAN761
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute search title once and hoist search regex in book search (#525)
useBooksForSearch recomputes on every keystroke over the whole library.
It compiled a fresh RegExp inside the .filter callback (once per book)
and called the expensive getMetadataFromBook twice inside the sort
comparator (~2·n·log(n) metadata merges). Hoist the regex to one
compilation per search and precompute each book's title once (n calls)
via decorate-sort-undecorate. Results and order are unchanged.
Benchmark at 3000 books: 149ms -> 15.5ms per search (~9.6x).
Claude-Session: https://claude.ai/code/session_01WTpoRzFz6L5YQ1MXtHE2Vf
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump next to 16.2.12 to fix DoS/SSRF/proxy-bypass advisories (#527)
Updates next and its lockstep companion eslint-config-next from 16.1.6 to
16.2.12 in @oboku/landing. Minor bump within major 16; patches ~9 high
severity advisories in the internet-facing landing app (Server Components
DoS, SSRF via WebSocket upgrades, App/Pages Router middleware & proxy
bypasses, i18n bypass).
Both were exact-pinned to 16.1.6; kept exact-pin style and moved them
together since eslint-config-next version-tracks next.
Claude-Session: https://claude.ai/code/session_01W73Y7fJypdhxKPp6YtHi65
Co-authored-by: Claude <noreply@anthropic.com>
* feat: ugprade vercel
* feat: upgrade
* chore(deps): bump sharp to 0.35.3 to fix libvips security advisory (#540)
Updates @oboku/api's sharp dependency from ^0.34.5 to ^0.35.3 to remediate
the high-severity advisory affecting sharp <0.35.0 (inherited libvips CVEs).
sharp 0.35 raises the minimum Node.js to >=20.9.0, satisfied by the pinned
Node 25 runtime. The image pipeline in covers.service.ts uses only stable
APIs (resize, toFormat, jpeg, webp, toBuffer), so no code changes are needed.
Claude-Session: https://claude.ai/code/session_014kuujQqjWVajzvvhrmqyuB
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate duplicated plugin link-info hooks (#539)
The dropbox, google, synology-drive and one-drive plugins each shipped a
byte-identical `useLinkInfo` that derives a `ID: <fileId>` label, and the
server and webdav plugins shipped an identical `filePath`-based one. Move
both implementations into `plugins/common/linkInfo.ts` as
`useFileIdLinkInfo` and `useFilePathLinkInfo`, and point each plugin at the
shared export. Behavior is unchanged; a plugin can still diverge later by
providing its own implementation.
Claude-Session: https://claude.ai/code/session_01KNBTACP3nnNjfsGvFh8i9z
Co-authored-by: Claude <noreply@anthropic.com>
* chore: remove strict dead code (unused exports) (#537)
Remove exports that knip flagged and that a repo-wide grep confirmed are
never imported or referenced anywhere (each appears only at its own
declaration; no dynamic/string/test references):
- apps/web/src/tags/helpers.ts: getProtectedTags, getTagsByIds (both
@deprecated), useTagsByIds
- apps/web/src/reader/states.ts: reader$, usePagination
- apps/web/src/collections/dbHelpers.ts: getCollections
- apps/api/src/lib/utils.ts: switchMapMergeOuter
- apps/api/src/lib/google/googleBooksApi.ts: findSeriesByTitle
- apps/api/src/lib/couch/dbHelpers.ts: findAllDataSources
Also drop the now-unused type imports left behind (Database, MangoResponse,
DataSourceDocType) and the rxjs/reactjrx imports that only those symbols used.
Claude-Session: https://claude.ai/code/session_013Xb8Rr8xPgnjmexMzNbZBW
Co-authored-by: Claude <noreply@anthropic.com>
* perf(web): linearize covers cache cleanup passes (#538)
The service-worker covers cache cleanup runs every 10 minutes (and on
startup) and had two super-linear passes over the cover cache:
- obsolete-cover detection matched each cache key against the book and
collection lists with `.some` (O(cacheKeys * (books + collections)))
- outdated-version detection compared every cache key against every
other key, re-parsing request headers each time (O(cacheKeys^2))
Replace the membership scan with a Set lookup and the pairwise scan with
a single max-time-per-coverId map. Both passes now parse each request's
headers once and produce byte-for-byte identical removal sets.
Claude-Session: https://claude.ai/code/session_01RNbzoWKvYxCqMT7PLKPTHQ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: single product version, automatic releases on master, versioned docker tags (#531)
* chore: align license metadata with GPL-3.0
The root LICENCE file is the full GNU GPL v3 text, but the README and
package manifests advertised MIT (and UNLICENSED for the API).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: publish a GitHub release when the root version changes
Tags were pushed by the web version bump, but no GitHub release was ever
created, so the repository exposed no installable version to pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: tag docker images with the version and commit sha
Images were published as :latest only, so an instance could not be pinned
to a known build. Version resolution moves to a shared job now that the
docker builds and the release both need it.
Release notes rewrite changelog links relative to the changelog into blob
URLs at the release tag, since relative targets do not resolve on a
release page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: single product version for the whole repo
The web app carried its own version line, bumped on every master push,
which was accidental rather than intended. Root package.json is now the
only version: the web app reads it through the __APP_VERSION__ build
constant, and the private unpublished manifests no longer declare one.
The persisted query cache moves to __BUILD_ID__ so it keeps busting per
build; keying it on a version that now changes only per release would let
a newer build rehydrate state persisted by an older one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* docs: document the release-notes script contract
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: mark workspace packages private and drop their versions
@oboku/shared was last published in 2022 and no publish flow exists, so
these are internal workspace packages like the apps: no version to carry,
and private stops an accidental npm publish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: bump the version automatically on every master push
The release version is now derived from conventional commits since the
last released tag (breaking -> major, feat -> minor, otherwise patch) and
committed back to master, so merging develop is the only manual step.
Downstream jobs build from the bump commit so the web app embeds the
version it ships under. A hand-edited, not-yet-released version in
package.json still wins, which also covers the pending 1.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* fix: tighten the breaking-change footer detection
The conventional commits spec allows both BREAKING CHANGE and
BREAKING-CHANGE as footer tokens, and requiring the colon stops prose
mentions from triggering a major bump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: replace hand-rolled version bump and release with semantic-release
The bump computation, tagging, release creation and notes generation move
to semantic-release with the conventionalcommits preset. A catch-all
releaseRules entry keeps the release-on-every-master-push behavior that
the default rules would skip for chore/docs-only pushes.
Upgrade instructions now travel in BREAKING CHANGE commit footers, which
release-notes-generator renders on the release page, so the gitbook
changelog becomes a pointer to GitHub releases and both custom release
scripts are deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.0.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@mbret@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(shared): match reader file extensions case-insensitively - #508

Merged
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21
Jul 27, 2026
Merged

fix(shared): match reader file extensions case-insensitively#508
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21

Conversation

@mbret

Copy link
Copy Markdown
Owner

The bug

isFileSupported (packages/shared/src/contentType.ts) builds the extension from the raw file name and compares it against the all-lowercase READER_ACCEPTED_EXTENSIONS list. Any file with an uppercase or mixed-case extension is reported as unsupported:

  • isFileSupported({ name: "MyBook.EPUB" })false (verified by executing the real code; "MyBook.epub"true)

How to observe it

isFileSupported is the gate for which files become books:

  • apps/api/src/plugins/synology-drive/client.ts:92 and apps/api/src/plugins/webdav/operations.ts:48 filter sync items with it (name only, no mime type available), so Book.EPUB / comic.CBZ on a NAS or WebDAV share is silently skipped during sync and never appears in the library.
  • apps/web/src/common/FileTreeView/LazyTreeView.tsx:195, apps/web/src/plugins/webdav/upload/FileBrowseStep.tsx:25, and apps/web/src/plugins/synology-drive/browsing/tree.ts:16 grey out / hide those files in the pickers.

Case-insensitivity is the established contract elsewhere: the sibling isPotentialZipFile in the same file lowercases the name first, and the archive reader's own getExtension lowercases too.

The fix

Lowercase the extracted extension in isFileSupported before comparing. getUrlExtension itself is left untouched (its casing is preserved for other consumers).

Verification

  • New regression test packages/shared/src/contentType.test.ts: MyBook.EPUB / comic.CBZ / document.Pdf fail on the previous code and pass after the fix; lowercase, mime-type, and unsupported-file cases covered.
  • npm run lint, npm run build (all 7 projects), shared suite 102/102 pass.
  • npm run test at repo root: only pre-existing failures remain — the same 15 tests in apps/web/src/http/HttpClientApi.web.test.ts + httpClientApi.sw.test.ts fail identically on a clean develop checkout (re-verified with this change stashed).

Other findings (not addressed in this PR)

Confirmed by tracing the real code paths; listed so they aren't lost:

  • bulkDelete omits _rev, so dangling-link deletion silently never deletes (apps/api/src/lib/couch/bulkDelete.ts): it posts {_id, _deleted: true} without _rev to _bulk_docs; CouchDB answers each doc with a conflict error (nano's bulk doesn't throw on per-doc errors), so deleteDanglingLinks records the link as deleted in the sync report while the doc survives. Dangling links accumulate and are "re-deleted" on every sync. The caller has _rev in hand (Helpers["find"] returns it) — the fix is to forward it.
  • useCollections' isNotInterested: "only" branch intersects the wrong array (apps/web/src/collections/useCollections.ts:152): it uses bookIds (the query param, undefined for the search screen caller) instead of the notInterestedBookIds computed just above — and intersection(collection.books, undefined) degenerates to the full book list. Setting the search filter "Show not interested contents: Only" therefore shows every non-empty collection instead of only collections with a not-interested book.
  • Empty Google metadata guard is always false (apps/api/src/lib/metadata/google/getGoogleBookMetadata.ts:98): if (!Object.keys(parsedMetadata)) return undefined — an array is always truthy (missing .length), so a no-result lookup still emits a field-less { type: "googleBookApi" } source entry onto the book.
  • createThrottler never clears its setInterval (apps/api/src/lib/utils.ts:97): each dropbox/google/one-drive sync spawns a 50 ms interval that lives for the whole process lifetime — a slow timer/CPU leak on a long-running server.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ


Generated by Claude Code

isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
obokuReadyReadyPreview, CommentJul 21, 2026 12:28pm

@mbret
mbret merged commit ba9dcda into developJul 27, 2026
4 checks passed
@mbret
mbret deleted the fix/bug-hunt-2026-07-21 branch July 27, 2026 12:18
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 5, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(api): consolidate synology-drive link session resolution (#526)
getFileMetadata, getFolderMetadata and download each repeated the same
~18-line preamble: validate connectorId/credentials/db, load the
connector, open a Synology Drive session and read fileId from the link.
Extract it into a single openSynologyDriveSessionForLink helper so the
validation and error handling live in one place.
Claude-Session: https://claude.ai/code/session_01SiouS4DW4vVoe9wfNAN761
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute search title once and hoist search regex in book search (#525)
useBooksForSearch recomputes on every keystroke over the whole library.
It compiled a fresh RegExp inside the .filter callback (once per book)
and called the expensive getMetadataFromBook twice inside the sort
comparator (~2·n·log(n) metadata merges). Hoist the regex to one
compilation per search and precompute each book's title once (n calls)
via decorate-sort-undecorate. Results and order are unchanged.
Benchmark at 3000 books: 149ms -> 15.5ms per search (~9.6x).
Claude-Session: https://claude.ai/code/session_01WTpoRzFz6L5YQ1MXtHE2Vf
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump next to 16.2.12 to fix DoS/SSRF/proxy-bypass advisories (#527)
Updates next and its lockstep companion eslint-config-next from 16.1.6 to
16.2.12 in @oboku/landing. Minor bump within major 16; patches ~9 high
severity advisories in the internet-facing landing app (Server Components
DoS, SSRF via WebSocket upgrades, App/Pages Router middleware & proxy
bypasses, i18n bypass).
Both were exact-pinned to 16.1.6; kept exact-pin style and moved them
together since eslint-config-next version-tracks next.
Claude-Session: https://claude.ai/code/session_01W73Y7fJypdhxKPp6YtHi65
Co-authored-by: Claude <noreply@anthropic.com>
* feat: ugprade vercel
* feat: upgrade
* chore(deps): bump sharp to 0.35.3 to fix libvips security advisory (#540)
Updates @oboku/api's sharp dependency from ^0.34.5 to ^0.35.3 to remediate
the high-severity advisory affecting sharp <0.35.0 (inherited libvips CVEs).
sharp 0.35 raises the minimum Node.js to >=20.9.0, satisfied by the pinned
Node 25 runtime. The image pipeline in covers.service.ts uses only stable
APIs (resize, toFormat, jpeg, webp, toBuffer), so no code changes are needed.
Claude-Session: https://claude.ai/code/session_014kuujQqjWVajzvvhrmqyuB
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate duplicated plugin link-info hooks (#539)
The dropbox, google, synology-drive and one-drive plugins each shipped a
byte-identical `useLinkInfo` that derives a `ID: <fileId>` label, and the
server and webdav plugins shipped an identical `filePath`-based one. Move
both implementations into `plugins/common/linkInfo.ts` as
`useFileIdLinkInfo` and `useFilePathLinkInfo`, and point each plugin at the
shared export. Behavior is unchanged; a plugin can still diverge later by
providing its own implementation.
Claude-Session: https://claude.ai/code/session_01KNBTACP3nnNjfsGvFh8i9z
Co-authored-by: Claude <noreply@anthropic.com>
* chore: remove strict dead code (unused exports) (#537)
Remove exports that knip flagged and that a repo-wide grep confirmed are
never imported or referenced anywhere (each appears only at its own
declaration; no dynamic/string/test references):
- apps/web/src/tags/helpers.ts: getProtectedTags, getTagsByIds (both
@deprecated), useTagsByIds
- apps/web/src/reader/states.ts: reader$, usePagination
- apps/web/src/collections/dbHelpers.ts: getCollections
- apps/api/src/lib/utils.ts: switchMapMergeOuter
- apps/api/src/lib/google/googleBooksApi.ts: findSeriesByTitle
- apps/api/src/lib/couch/dbHelpers.ts: findAllDataSources
Also drop the now-unused type imports left behind (Database, MangoResponse,
DataSourceDocType) and the rxjs/reactjrx imports that only those symbols used.
Claude-Session: https://claude.ai/code/session_013Xb8Rr8xPgnjmexMzNbZBW
Co-authored-by: Claude <noreply@anthropic.com>
* perf(web): linearize covers cache cleanup passes (#538)
The service-worker covers cache cleanup runs every 10 minutes (and on
startup) and had two super-linear passes over the cover cache:
- obsolete-cover detection matched each cache key against the book and
collection lists with `.some` (O(cacheKeys * (books + collections)))
- outdated-version detection compared every cache key against every
other key, re-parsing request headers each time (O(cacheKeys^2))
Replace the membership scan with a Set lookup and the pairwise scan with
a single max-time-per-coverId map. Both passes now parse each request's
headers once and produce byte-for-byte identical removal sets.
Claude-Session: https://claude.ai/code/session_01RNbzoWKvYxCqMT7PLKPTHQ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: single product version, automatic releases on master, versioned docker tags (#531)
* chore: align license metadata with GPL-3.0
The root LICENCE file is the full GNU GPL v3 text, but the README and
package manifests advertised MIT (and UNLICENSED for the API).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: publish a GitHub release when the root version changes
Tags were pushed by the web version bump, but no GitHub release was ever
created, so the repository exposed no installable version to pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: tag docker images with the version and commit sha
Images were published as :latest only, so an instance could not be pinned
to a known build. Version resolution moves to a shared job now that the
docker builds and the release both need it.
Release notes rewrite changelog links relative to the changelog into blob
URLs at the release tag, since relative targets do not resolve on a
release page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: single product version for the whole repo
The web app carried its own version line, bumped on every master push,
which was accidental rather than intended. Root package.json is now the
only version: the web app reads it through the __APP_VERSION__ build
constant, and the private unpublished manifests no longer declare one.
The persisted query cache moves to __BUILD_ID__ so it keeps busting per
build; keying it on a version that now changes only per release would let
a newer build rehydrate state persisted by an older one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* docs: document the release-notes script contract
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: mark workspace packages private and drop their versions
@oboku/shared was last published in 2022 and no publish flow exists, so
these are internal workspace packages like the apps: no version to carry,
and private stops an accidental npm publish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: bump the version automatically on every master push
The release version is now derived from conventional commits since the
last released tag (breaking -> major, feat -> minor, otherwise patch) and
committed back to master, so merging develop is the only manual step.
Downstream jobs build from the bump commit so the web app embeds the
version it ships under. A hand-edited, not-yet-released version in
package.json still wins, which also covers the pending 1.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* fix: tighten the breaking-change footer detection
The conventional commits spec allows both BREAKING CHANGE and
BREAKING-CHANGE as footer tokens, and requiring the colon stops prose
mentions from triggering a major bump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: replace hand-rolled version bump and release with semantic-release
The bump computation, tagging, release creation and notes generation move
to semantic-release with the conventionalcommits preset. A catch-all
releaseRules entry keeps the release-on-every-master-push behavior that
the default rules would skip for chore/docs-only pushes.
Upgrade instructions now travel in BREAKING CHANGE commit footers, which
release-notes-generator renders on the release page, so the gitbook
changelog becomes a pointer to GitHub releases and both custom release
scripts are deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.0.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@mbret@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(shared): match reader file extensions case-insensitively - #508

Merged
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21
Jul 27, 2026
Merged

fix(shared): match reader file extensions case-insensitively#508
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21

Conversation

@mbret

Copy link
Copy Markdown
Owner

The bug

isFileSupported (packages/shared/src/contentType.ts) builds the extension from the raw file name and compares it against the all-lowercase READER_ACCEPTED_EXTENSIONS list. Any file with an uppercase or mixed-case extension is reported as unsupported:

  • isFileSupported({ name: "MyBook.EPUB" })false (verified by executing the real code; "MyBook.epub"true)

How to observe it

isFileSupported is the gate for which files become books:

  • apps/api/src/plugins/synology-drive/client.ts:92 and apps/api/src/plugins/webdav/operations.ts:48 filter sync items with it (name only, no mime type available), so Book.EPUB / comic.CBZ on a NAS or WebDAV share is silently skipped during sync and never appears in the library.
  • apps/web/src/common/FileTreeView/LazyTreeView.tsx:195, apps/web/src/plugins/webdav/upload/FileBrowseStep.tsx:25, and apps/web/src/plugins/synology-drive/browsing/tree.ts:16 grey out / hide those files in the pickers.

Case-insensitivity is the established contract elsewhere: the sibling isPotentialZipFile in the same file lowercases the name first, and the archive reader's own getExtension lowercases too.

The fix

Lowercase the extracted extension in isFileSupported before comparing. getUrlExtension itself is left untouched (its casing is preserved for other consumers).

Verification

  • New regression test packages/shared/src/contentType.test.ts: MyBook.EPUB / comic.CBZ / document.Pdf fail on the previous code and pass after the fix; lowercase, mime-type, and unsupported-file cases covered.
  • npm run lint, npm run build (all 7 projects), shared suite 102/102 pass.
  • npm run test at repo root: only pre-existing failures remain — the same 15 tests in apps/web/src/http/HttpClientApi.web.test.ts + httpClientApi.sw.test.ts fail identically on a clean develop checkout (re-verified with this change stashed).

Other findings (not addressed in this PR)

Confirmed by tracing the real code paths; listed so they aren't lost:

  • bulkDelete omits _rev, so dangling-link deletion silently never deletes (apps/api/src/lib/couch/bulkDelete.ts): it posts {_id, _deleted: true} without _rev to _bulk_docs; CouchDB answers each doc with a conflict error (nano's bulk doesn't throw on per-doc errors), so deleteDanglingLinks records the link as deleted in the sync report while the doc survives. Dangling links accumulate and are "re-deleted" on every sync. The caller has _rev in hand (Helpers["find"] returns it) — the fix is to forward it.
  • useCollections' isNotInterested: "only" branch intersects the wrong array (apps/web/src/collections/useCollections.ts:152): it uses bookIds (the query param, undefined for the search screen caller) instead of the notInterestedBookIds computed just above — and intersection(collection.books, undefined) degenerates to the full book list. Setting the search filter "Show not interested contents: Only" therefore shows every non-empty collection instead of only collections with a not-interested book.
  • Empty Google metadata guard is always false (apps/api/src/lib/metadata/google/getGoogleBookMetadata.ts:98): if (!Object.keys(parsedMetadata)) return undefined — an array is always truthy (missing .length), so a no-result lookup still emits a field-less { type: "googleBookApi" } source entry onto the book.
  • createThrottler never clears its setInterval (apps/api/src/lib/utils.ts:97): each dropbox/google/one-drive sync spawns a 50 ms interval that lives for the whole process lifetime — a slow timer/CPU leak on a long-running server.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ


Generated by Claude Code

isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
obokuReadyReadyPreview, CommentJul 21, 2026 12:28pm

@mbret
mbret merged commit ba9dcda into developJul 27, 2026
4 checks passed
@mbret
mbret deleted the fix/bug-hunt-2026-07-21 branch July 27, 2026 12:18
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 5, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(api): consolidate synology-drive link session resolution (#526)
getFileMetadata, getFolderMetadata and download each repeated the same
~18-line preamble: validate connectorId/credentials/db, load the
connector, open a Synology Drive session and read fileId from the link.
Extract it into a single openSynologyDriveSessionForLink helper so the
validation and error handling live in one place.
Claude-Session: https://claude.ai/code/session_01SiouS4DW4vVoe9wfNAN761
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute search title once and hoist search regex in book search (#525)
useBooksForSearch recomputes on every keystroke over the whole library.
It compiled a fresh RegExp inside the .filter callback (once per book)
and called the expensive getMetadataFromBook twice inside the sort
comparator (~2·n·log(n) metadata merges). Hoist the regex to one
compilation per search and precompute each book's title once (n calls)
via decorate-sort-undecorate. Results and order are unchanged.
Benchmark at 3000 books: 149ms -> 15.5ms per search (~9.6x).
Claude-Session: https://claude.ai/code/session_01WTpoRzFz6L5YQ1MXtHE2Vf
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump next to 16.2.12 to fix DoS/SSRF/proxy-bypass advisories (#527)
Updates next and its lockstep companion eslint-config-next from 16.1.6 to
16.2.12 in @oboku/landing. Minor bump within major 16; patches ~9 high
severity advisories in the internet-facing landing app (Server Components
DoS, SSRF via WebSocket upgrades, App/Pages Router middleware & proxy
bypasses, i18n bypass).
Both were exact-pinned to 16.1.6; kept exact-pin style and moved them
together since eslint-config-next version-tracks next.
Claude-Session: https://claude.ai/code/session_01W73Y7fJypdhxKPp6YtHi65
Co-authored-by: Claude <noreply@anthropic.com>
* feat: ugprade vercel
* feat: upgrade
* chore(deps): bump sharp to 0.35.3 to fix libvips security advisory (#540)
Updates @oboku/api's sharp dependency from ^0.34.5 to ^0.35.3 to remediate
the high-severity advisory affecting sharp <0.35.0 (inherited libvips CVEs).
sharp 0.35 raises the minimum Node.js to >=20.9.0, satisfied by the pinned
Node 25 runtime. The image pipeline in covers.service.ts uses only stable
APIs (resize, toFormat, jpeg, webp, toBuffer), so no code changes are needed.
Claude-Session: https://claude.ai/code/session_014kuujQqjWVajzvvhrmqyuB
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate duplicated plugin link-info hooks (#539)
The dropbox, google, synology-drive and one-drive plugins each shipped a
byte-identical `useLinkInfo` that derives a `ID: <fileId>` label, and the
server and webdav plugins shipped an identical `filePath`-based one. Move
both implementations into `plugins/common/linkInfo.ts` as
`useFileIdLinkInfo` and `useFilePathLinkInfo`, and point each plugin at the
shared export. Behavior is unchanged; a plugin can still diverge later by
providing its own implementation.
Claude-Session: https://claude.ai/code/session_01KNBTACP3nnNjfsGvFh8i9z
Co-authored-by: Claude <noreply@anthropic.com>
* chore: remove strict dead code (unused exports) (#537)
Remove exports that knip flagged and that a repo-wide grep confirmed are
never imported or referenced anywhere (each appears only at its own
declaration; no dynamic/string/test references):
- apps/web/src/tags/helpers.ts: getProtectedTags, getTagsByIds (both
@deprecated), useTagsByIds
- apps/web/src/reader/states.ts: reader$, usePagination
- apps/web/src/collections/dbHelpers.ts: getCollections
- apps/api/src/lib/utils.ts: switchMapMergeOuter
- apps/api/src/lib/google/googleBooksApi.ts: findSeriesByTitle
- apps/api/src/lib/couch/dbHelpers.ts: findAllDataSources
Also drop the now-unused type imports left behind (Database, MangoResponse,
DataSourceDocType) and the rxjs/reactjrx imports that only those symbols used.
Claude-Session: https://claude.ai/code/session_013Xb8Rr8xPgnjmexMzNbZBW
Co-authored-by: Claude <noreply@anthropic.com>
* perf(web): linearize covers cache cleanup passes (#538)
The service-worker covers cache cleanup runs every 10 minutes (and on
startup) and had two super-linear passes over the cover cache:
- obsolete-cover detection matched each cache key against the book and
collection lists with `.some` (O(cacheKeys * (books + collections)))
- outdated-version detection compared every cache key against every
other key, re-parsing request headers each time (O(cacheKeys^2))
Replace the membership scan with a Set lookup and the pairwise scan with
a single max-time-per-coverId map. Both passes now parse each request's
headers once and produce byte-for-byte identical removal sets.
Claude-Session: https://claude.ai/code/session_01RNbzoWKvYxCqMT7PLKPTHQ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: single product version, automatic releases on master, versioned docker tags (#531)
* chore: align license metadata with GPL-3.0
The root LICENCE file is the full GNU GPL v3 text, but the README and
package manifests advertised MIT (and UNLICENSED for the API).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: publish a GitHub release when the root version changes
Tags were pushed by the web version bump, but no GitHub release was ever
created, so the repository exposed no installable version to pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: tag docker images with the version and commit sha
Images were published as :latest only, so an instance could not be pinned
to a known build. Version resolution moves to a shared job now that the
docker builds and the release both need it.
Release notes rewrite changelog links relative to the changelog into blob
URLs at the release tag, since relative targets do not resolve on a
release page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: single product version for the whole repo
The web app carried its own version line, bumped on every master push,
which was accidental rather than intended. Root package.json is now the
only version: the web app reads it through the __APP_VERSION__ build
constant, and the private unpublished manifests no longer declare one.
The persisted query cache moves to __BUILD_ID__ so it keeps busting per
build; keying it on a version that now changes only per release would let
a newer build rehydrate state persisted by an older one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* docs: document the release-notes script contract
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: mark workspace packages private and drop their versions
@oboku/shared was last published in 2022 and no publish flow exists, so
these are internal workspace packages like the apps: no version to carry,
and private stops an accidental npm publish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: bump the version automatically on every master push
The release version is now derived from conventional commits since the
last released tag (breaking -> major, feat -> minor, otherwise patch) and
committed back to master, so merging develop is the only manual step.
Downstream jobs build from the bump commit so the web app embeds the
version it ships under. A hand-edited, not-yet-released version in
package.json still wins, which also covers the pending 1.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* fix: tighten the breaking-change footer detection
The conventional commits spec allows both BREAKING CHANGE and
BREAKING-CHANGE as footer tokens, and requiring the colon stops prose
mentions from triggering a major bump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: replace hand-rolled version bump and release with semantic-release
The bump computation, tagging, release creation and notes generation move
to semantic-release with the conventionalcommits preset. A catch-all
releaseRules entry keeps the release-on-every-master-push behavior that
the default rules would skip for chore/docs-only pushes.
Upgrade instructions now travel in BREAKING CHANGE commit footers, which
release-notes-generator renders on the release page, so the gitbook
changelog becomes a pointer to GitHub releases and both custom release
scripts are deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.0.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@mbret@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(shared): match reader file extensions case-insensitively - #508

Merged
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21
Jul 27, 2026
Merged

fix(shared): match reader file extensions case-insensitively#508
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21

Conversation

@mbret

Copy link
Copy Markdown
Owner

The bug

isFileSupported (packages/shared/src/contentType.ts) builds the extension from the raw file name and compares it against the all-lowercase READER_ACCEPTED_EXTENSIONS list. Any file with an uppercase or mixed-case extension is reported as unsupported:

  • isFileSupported({ name: "MyBook.EPUB" })false (verified by executing the real code; "MyBook.epub"true)

How to observe it

isFileSupported is the gate for which files become books:

  • apps/api/src/plugins/synology-drive/client.ts:92 and apps/api/src/plugins/webdav/operations.ts:48 filter sync items with it (name only, no mime type available), so Book.EPUB / comic.CBZ on a NAS or WebDAV share is silently skipped during sync and never appears in the library.
  • apps/web/src/common/FileTreeView/LazyTreeView.tsx:195, apps/web/src/plugins/webdav/upload/FileBrowseStep.tsx:25, and apps/web/src/plugins/synology-drive/browsing/tree.ts:16 grey out / hide those files in the pickers.

Case-insensitivity is the established contract elsewhere: the sibling isPotentialZipFile in the same file lowercases the name first, and the archive reader's own getExtension lowercases too.

The fix

Lowercase the extracted extension in isFileSupported before comparing. getUrlExtension itself is left untouched (its casing is preserved for other consumers).

Verification

  • New regression test packages/shared/src/contentType.test.ts: MyBook.EPUB / comic.CBZ / document.Pdf fail on the previous code and pass after the fix; lowercase, mime-type, and unsupported-file cases covered.
  • npm run lint, npm run build (all 7 projects), shared suite 102/102 pass.
  • npm run test at repo root: only pre-existing failures remain — the same 15 tests in apps/web/src/http/HttpClientApi.web.test.ts + httpClientApi.sw.test.ts fail identically on a clean develop checkout (re-verified with this change stashed).

Other findings (not addressed in this PR)

Confirmed by tracing the real code paths; listed so they aren't lost:

  • bulkDelete omits _rev, so dangling-link deletion silently never deletes (apps/api/src/lib/couch/bulkDelete.ts): it posts {_id, _deleted: true} without _rev to _bulk_docs; CouchDB answers each doc with a conflict error (nano's bulk doesn't throw on per-doc errors), so deleteDanglingLinks records the link as deleted in the sync report while the doc survives. Dangling links accumulate and are "re-deleted" on every sync. The caller has _rev in hand (Helpers["find"] returns it) — the fix is to forward it.
  • useCollections' isNotInterested: "only" branch intersects the wrong array (apps/web/src/collections/useCollections.ts:152): it uses bookIds (the query param, undefined for the search screen caller) instead of the notInterestedBookIds computed just above — and intersection(collection.books, undefined) degenerates to the full book list. Setting the search filter "Show not interested contents: Only" therefore shows every non-empty collection instead of only collections with a not-interested book.
  • Empty Google metadata guard is always false (apps/api/src/lib/metadata/google/getGoogleBookMetadata.ts:98): if (!Object.keys(parsedMetadata)) return undefined — an array is always truthy (missing .length), so a no-result lookup still emits a field-less { type: "googleBookApi" } source entry onto the book.
  • createThrottler never clears its setInterval (apps/api/src/lib/utils.ts:97): each dropbox/google/one-drive sync spawns a 50 ms interval that lives for the whole process lifetime — a slow timer/CPU leak on a long-running server.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ


Generated by Claude Code

isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
obokuReadyReadyPreview, CommentJul 21, 2026 12:28pm

@mbret
mbret merged commit ba9dcda into developJul 27, 2026
4 checks passed
@mbret
mbret deleted the fix/bug-hunt-2026-07-21 branch July 27, 2026 12:18
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 5, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(api): consolidate synology-drive link session resolution (#526)
getFileMetadata, getFolderMetadata and download each repeated the same
~18-line preamble: validate connectorId/credentials/db, load the
connector, open a Synology Drive session and read fileId from the link.
Extract it into a single openSynologyDriveSessionForLink helper so the
validation and error handling live in one place.
Claude-Session: https://claude.ai/code/session_01SiouS4DW4vVoe9wfNAN761
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute search title once and hoist search regex in book search (#525)
useBooksForSearch recomputes on every keystroke over the whole library.
It compiled a fresh RegExp inside the .filter callback (once per book)
and called the expensive getMetadataFromBook twice inside the sort
comparator (~2·n·log(n) metadata merges). Hoist the regex to one
compilation per search and precompute each book's title once (n calls)
via decorate-sort-undecorate. Results and order are unchanged.
Benchmark at 3000 books: 149ms -> 15.5ms per search (~9.6x).
Claude-Session: https://claude.ai/code/session_01WTpoRzFz6L5YQ1MXtHE2Vf
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump next to 16.2.12 to fix DoS/SSRF/proxy-bypass advisories (#527)
Updates next and its lockstep companion eslint-config-next from 16.1.6 to
16.2.12 in @oboku/landing. Minor bump within major 16; patches ~9 high
severity advisories in the internet-facing landing app (Server Components
DoS, SSRF via WebSocket upgrades, App/Pages Router middleware & proxy
bypasses, i18n bypass).
Both were exact-pinned to 16.1.6; kept exact-pin style and moved them
together since eslint-config-next version-tracks next.
Claude-Session: https://claude.ai/code/session_01W73Y7fJypdhxKPp6YtHi65
Co-authored-by: Claude <noreply@anthropic.com>
* feat: ugprade vercel
* feat: upgrade
* chore(deps): bump sharp to 0.35.3 to fix libvips security advisory (#540)
Updates @oboku/api's sharp dependency from ^0.34.5 to ^0.35.3 to remediate
the high-severity advisory affecting sharp <0.35.0 (inherited libvips CVEs).
sharp 0.35 raises the minimum Node.js to >=20.9.0, satisfied by the pinned
Node 25 runtime. The image pipeline in covers.service.ts uses only stable
APIs (resize, toFormat, jpeg, webp, toBuffer), so no code changes are needed.
Claude-Session: https://claude.ai/code/session_014kuujQqjWVajzvvhrmqyuB
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate duplicated plugin link-info hooks (#539)
The dropbox, google, synology-drive and one-drive plugins each shipped a
byte-identical `useLinkInfo` that derives a `ID: <fileId>` label, and the
server and webdav plugins shipped an identical `filePath`-based one. Move
both implementations into `plugins/common/linkInfo.ts` as
`useFileIdLinkInfo` and `useFilePathLinkInfo`, and point each plugin at the
shared export. Behavior is unchanged; a plugin can still diverge later by
providing its own implementation.
Claude-Session: https://claude.ai/code/session_01KNBTACP3nnNjfsGvFh8i9z
Co-authored-by: Claude <noreply@anthropic.com>
* chore: remove strict dead code (unused exports) (#537)
Remove exports that knip flagged and that a repo-wide grep confirmed are
never imported or referenced anywhere (each appears only at its own
declaration; no dynamic/string/test references):
- apps/web/src/tags/helpers.ts: getProtectedTags, getTagsByIds (both
@deprecated), useTagsByIds
- apps/web/src/reader/states.ts: reader$, usePagination
- apps/web/src/collections/dbHelpers.ts: getCollections
- apps/api/src/lib/utils.ts: switchMapMergeOuter
- apps/api/src/lib/google/googleBooksApi.ts: findSeriesByTitle
- apps/api/src/lib/couch/dbHelpers.ts: findAllDataSources
Also drop the now-unused type imports left behind (Database, MangoResponse,
DataSourceDocType) and the rxjs/reactjrx imports that only those symbols used.
Claude-Session: https://claude.ai/code/session_013Xb8Rr8xPgnjmexMzNbZBW
Co-authored-by: Claude <noreply@anthropic.com>
* perf(web): linearize covers cache cleanup passes (#538)
The service-worker covers cache cleanup runs every 10 minutes (and on
startup) and had two super-linear passes over the cover cache:
- obsolete-cover detection matched each cache key against the book and
collection lists with `.some` (O(cacheKeys * (books + collections)))
- outdated-version detection compared every cache key against every
other key, re-parsing request headers each time (O(cacheKeys^2))
Replace the membership scan with a Set lookup and the pairwise scan with
a single max-time-per-coverId map. Both passes now parse each request's
headers once and produce byte-for-byte identical removal sets.
Claude-Session: https://claude.ai/code/session_01RNbzoWKvYxCqMT7PLKPTHQ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: single product version, automatic releases on master, versioned docker tags (#531)
* chore: align license metadata with GPL-3.0
The root LICENCE file is the full GNU GPL v3 text, but the README and
package manifests advertised MIT (and UNLICENSED for the API).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: publish a GitHub release when the root version changes
Tags were pushed by the web version bump, but no GitHub release was ever
created, so the repository exposed no installable version to pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: tag docker images with the version and commit sha
Images were published as :latest only, so an instance could not be pinned
to a known build. Version resolution moves to a shared job now that the
docker builds and the release both need it.
Release notes rewrite changelog links relative to the changelog into blob
URLs at the release tag, since relative targets do not resolve on a
release page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: single product version for the whole repo
The web app carried its own version line, bumped on every master push,
which was accidental rather than intended. Root package.json is now the
only version: the web app reads it through the __APP_VERSION__ build
constant, and the private unpublished manifests no longer declare one.
The persisted query cache moves to __BUILD_ID__ so it keeps busting per
build; keying it on a version that now changes only per release would let
a newer build rehydrate state persisted by an older one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* docs: document the release-notes script contract
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: mark workspace packages private and drop their versions
@oboku/shared was last published in 2022 and no publish flow exists, so
these are internal workspace packages like the apps: no version to carry,
and private stops an accidental npm publish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: bump the version automatically on every master push
The release version is now derived from conventional commits since the
last released tag (breaking -> major, feat -> minor, otherwise patch) and
committed back to master, so merging develop is the only manual step.
Downstream jobs build from the bump commit so the web app embeds the
version it ships under. A hand-edited, not-yet-released version in
package.json still wins, which also covers the pending 1.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* fix: tighten the breaking-change footer detection
The conventional commits spec allows both BREAKING CHANGE and
BREAKING-CHANGE as footer tokens, and requiring the colon stops prose
mentions from triggering a major bump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: replace hand-rolled version bump and release with semantic-release
The bump computation, tagging, release creation and notes generation move
to semantic-release with the conventionalcommits preset. A catch-all
releaseRules entry keeps the release-on-every-master-push behavior that
the default rules would skip for chore/docs-only pushes.
Upgrade instructions now travel in BREAKING CHANGE commit footers, which
release-notes-generator renders on the release page, so the gitbook
changelog becomes a pointer to GitHub releases and both custom release
scripts are deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.0.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@mbret@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(shared): match reader file extensions case-insensitively - #508

Merged
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21
Jul 27, 2026
Merged

fix(shared): match reader file extensions case-insensitively#508
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21

Conversation

@mbret

Copy link
Copy Markdown
Owner

The bug

isFileSupported (packages/shared/src/contentType.ts) builds the extension from the raw file name and compares it against the all-lowercase READER_ACCEPTED_EXTENSIONS list. Any file with an uppercase or mixed-case extension is reported as unsupported:

  • isFileSupported({ name: "MyBook.EPUB" })false (verified by executing the real code; "MyBook.epub"true)

How to observe it

isFileSupported is the gate for which files become books:

  • apps/api/src/plugins/synology-drive/client.ts:92 and apps/api/src/plugins/webdav/operations.ts:48 filter sync items with it (name only, no mime type available), so Book.EPUB / comic.CBZ on a NAS or WebDAV share is silently skipped during sync and never appears in the library.
  • apps/web/src/common/FileTreeView/LazyTreeView.tsx:195, apps/web/src/plugins/webdav/upload/FileBrowseStep.tsx:25, and apps/web/src/plugins/synology-drive/browsing/tree.ts:16 grey out / hide those files in the pickers.

Case-insensitivity is the established contract elsewhere: the sibling isPotentialZipFile in the same file lowercases the name first, and the archive reader's own getExtension lowercases too.

The fix

Lowercase the extracted extension in isFileSupported before comparing. getUrlExtension itself is left untouched (its casing is preserved for other consumers).

Verification

  • New regression test packages/shared/src/contentType.test.ts: MyBook.EPUB / comic.CBZ / document.Pdf fail on the previous code and pass after the fix; lowercase, mime-type, and unsupported-file cases covered.
  • npm run lint, npm run build (all 7 projects), shared suite 102/102 pass.
  • npm run test at repo root: only pre-existing failures remain — the same 15 tests in apps/web/src/http/HttpClientApi.web.test.ts + httpClientApi.sw.test.ts fail identically on a clean develop checkout (re-verified with this change stashed).

Other findings (not addressed in this PR)

Confirmed by tracing the real code paths; listed so they aren't lost:

  • bulkDelete omits _rev, so dangling-link deletion silently never deletes (apps/api/src/lib/couch/bulkDelete.ts): it posts {_id, _deleted: true} without _rev to _bulk_docs; CouchDB answers each doc with a conflict error (nano's bulk doesn't throw on per-doc errors), so deleteDanglingLinks records the link as deleted in the sync report while the doc survives. Dangling links accumulate and are "re-deleted" on every sync. The caller has _rev in hand (Helpers["find"] returns it) — the fix is to forward it.
  • useCollections' isNotInterested: "only" branch intersects the wrong array (apps/web/src/collections/useCollections.ts:152): it uses bookIds (the query param, undefined for the search screen caller) instead of the notInterestedBookIds computed just above — and intersection(collection.books, undefined) degenerates to the full book list. Setting the search filter "Show not interested contents: Only" therefore shows every non-empty collection instead of only collections with a not-interested book.
  • Empty Google metadata guard is always false (apps/api/src/lib/metadata/google/getGoogleBookMetadata.ts:98): if (!Object.keys(parsedMetadata)) return undefined — an array is always truthy (missing .length), so a no-result lookup still emits a field-less { type: "googleBookApi" } source entry onto the book.
  • createThrottler never clears its setInterval (apps/api/src/lib/utils.ts:97): each dropbox/google/one-drive sync spawns a 50 ms interval that lives for the whole process lifetime — a slow timer/CPU leak on a long-running server.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ


Generated by Claude Code

isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
obokuReadyReadyPreview, CommentJul 21, 2026 12:28pm

@mbret
mbret merged commit ba9dcda into developJul 27, 2026
4 checks passed
@mbret
mbret deleted the fix/bug-hunt-2026-07-21 branch July 27, 2026 12:18
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 5, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(api): consolidate synology-drive link session resolution (#526)
getFileMetadata, getFolderMetadata and download each repeated the same
~18-line preamble: validate connectorId/credentials/db, load the
connector, open a Synology Drive session and read fileId from the link.
Extract it into a single openSynologyDriveSessionForLink helper so the
validation and error handling live in one place.
Claude-Session: https://claude.ai/code/session_01SiouS4DW4vVoe9wfNAN761
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute search title once and hoist search regex in book search (#525)
useBooksForSearch recomputes on every keystroke over the whole library.
It compiled a fresh RegExp inside the .filter callback (once per book)
and called the expensive getMetadataFromBook twice inside the sort
comparator (~2·n·log(n) metadata merges). Hoist the regex to one
compilation per search and precompute each book's title once (n calls)
via decorate-sort-undecorate. Results and order are unchanged.
Benchmark at 3000 books: 149ms -> 15.5ms per search (~9.6x).
Claude-Session: https://claude.ai/code/session_01WTpoRzFz6L5YQ1MXtHE2Vf
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump next to 16.2.12 to fix DoS/SSRF/proxy-bypass advisories (#527)
Updates next and its lockstep companion eslint-config-next from 16.1.6 to
16.2.12 in @oboku/landing. Minor bump within major 16; patches ~9 high
severity advisories in the internet-facing landing app (Server Components
DoS, SSRF via WebSocket upgrades, App/Pages Router middleware & proxy
bypasses, i18n bypass).
Both were exact-pinned to 16.1.6; kept exact-pin style and moved them
together since eslint-config-next version-tracks next.
Claude-Session: https://claude.ai/code/session_01W73Y7fJypdhxKPp6YtHi65
Co-authored-by: Claude <noreply@anthropic.com>
* feat: ugprade vercel
* feat: upgrade
* chore(deps): bump sharp to 0.35.3 to fix libvips security advisory (#540)
Updates @oboku/api's sharp dependency from ^0.34.5 to ^0.35.3 to remediate
the high-severity advisory affecting sharp <0.35.0 (inherited libvips CVEs).
sharp 0.35 raises the minimum Node.js to >=20.9.0, satisfied by the pinned
Node 25 runtime. The image pipeline in covers.service.ts uses only stable
APIs (resize, toFormat, jpeg, webp, toBuffer), so no code changes are needed.
Claude-Session: https://claude.ai/code/session_014kuujQqjWVajzvvhrmqyuB
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate duplicated plugin link-info hooks (#539)
The dropbox, google, synology-drive and one-drive plugins each shipped a
byte-identical `useLinkInfo` that derives a `ID: <fileId>` label, and the
server and webdav plugins shipped an identical `filePath`-based one. Move
both implementations into `plugins/common/linkInfo.ts` as
`useFileIdLinkInfo` and `useFilePathLinkInfo`, and point each plugin at the
shared export. Behavior is unchanged; a plugin can still diverge later by
providing its own implementation.
Claude-Session: https://claude.ai/code/session_01KNBTACP3nnNjfsGvFh8i9z
Co-authored-by: Claude <noreply@anthropic.com>
* chore: remove strict dead code (unused exports) (#537)
Remove exports that knip flagged and that a repo-wide grep confirmed are
never imported or referenced anywhere (each appears only at its own
declaration; no dynamic/string/test references):
- apps/web/src/tags/helpers.ts: getProtectedTags, getTagsByIds (both
@deprecated), useTagsByIds
- apps/web/src/reader/states.ts: reader$, usePagination
- apps/web/src/collections/dbHelpers.ts: getCollections
- apps/api/src/lib/utils.ts: switchMapMergeOuter
- apps/api/src/lib/google/googleBooksApi.ts: findSeriesByTitle
- apps/api/src/lib/couch/dbHelpers.ts: findAllDataSources
Also drop the now-unused type imports left behind (Database, MangoResponse,
DataSourceDocType) and the rxjs/reactjrx imports that only those symbols used.
Claude-Session: https://claude.ai/code/session_013Xb8Rr8xPgnjmexMzNbZBW
Co-authored-by: Claude <noreply@anthropic.com>
* perf(web): linearize covers cache cleanup passes (#538)
The service-worker covers cache cleanup runs every 10 minutes (and on
startup) and had two super-linear passes over the cover cache:
- obsolete-cover detection matched each cache key against the book and
collection lists with `.some` (O(cacheKeys * (books + collections)))
- outdated-version detection compared every cache key against every
other key, re-parsing request headers each time (O(cacheKeys^2))
Replace the membership scan with a Set lookup and the pairwise scan with
a single max-time-per-coverId map. Both passes now parse each request's
headers once and produce byte-for-byte identical removal sets.
Claude-Session: https://claude.ai/code/session_01RNbzoWKvYxCqMT7PLKPTHQ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: single product version, automatic releases on master, versioned docker tags (#531)
* chore: align license metadata with GPL-3.0
The root LICENCE file is the full GNU GPL v3 text, but the README and
package manifests advertised MIT (and UNLICENSED for the API).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: publish a GitHub release when the root version changes
Tags were pushed by the web version bump, but no GitHub release was ever
created, so the repository exposed no installable version to pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: tag docker images with the version and commit sha
Images were published as :latest only, so an instance could not be pinned
to a known build. Version resolution moves to a shared job now that the
docker builds and the release both need it.
Release notes rewrite changelog links relative to the changelog into blob
URLs at the release tag, since relative targets do not resolve on a
release page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: single product version for the whole repo
The web app carried its own version line, bumped on every master push,
which was accidental rather than intended. Root package.json is now the
only version: the web app reads it through the __APP_VERSION__ build
constant, and the private unpublished manifests no longer declare one.
The persisted query cache moves to __BUILD_ID__ so it keeps busting per
build; keying it on a version that now changes only per release would let
a newer build rehydrate state persisted by an older one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* docs: document the release-notes script contract
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: mark workspace packages private and drop their versions
@oboku/shared was last published in 2022 and no publish flow exists, so
these are internal workspace packages like the apps: no version to carry,
and private stops an accidental npm publish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: bump the version automatically on every master push
The release version is now derived from conventional commits since the
last released tag (breaking -> major, feat -> minor, otherwise patch) and
committed back to master, so merging develop is the only manual step.
Downstream jobs build from the bump commit so the web app embeds the
version it ships under. A hand-edited, not-yet-released version in
package.json still wins, which also covers the pending 1.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* fix: tighten the breaking-change footer detection
The conventional commits spec allows both BREAKING CHANGE and
BREAKING-CHANGE as footer tokens, and requiring the colon stops prose
mentions from triggering a major bump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: replace hand-rolled version bump and release with semantic-release
The bump computation, tagging, release creation and notes generation move
to semantic-release with the conventionalcommits preset. A catch-all
releaseRules entry keeps the release-on-every-master-push behavior that
the default rules would skip for chore/docs-only pushes.
Upgrade instructions now travel in BREAKING CHANGE commit footers, which
release-notes-generator renders on the release page, so the gitbook
changelog becomes a pointer to GitHub releases and both custom release
scripts are deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.0.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@mbret@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(shared): match reader file extensions case-insensitively - #508

Merged
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21
Jul 27, 2026
Merged

fix(shared): match reader file extensions case-insensitively#508
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21

Conversation

@mbret

Copy link
Copy Markdown
Owner

The bug

isFileSupported (packages/shared/src/contentType.ts) builds the extension from the raw file name and compares it against the all-lowercase READER_ACCEPTED_EXTENSIONS list. Any file with an uppercase or mixed-case extension is reported as unsupported:

  • isFileSupported({ name: "MyBook.EPUB" })false (verified by executing the real code; "MyBook.epub"true)

How to observe it

isFileSupported is the gate for which files become books:

  • apps/api/src/plugins/synology-drive/client.ts:92 and apps/api/src/plugins/webdav/operations.ts:48 filter sync items with it (name only, no mime type available), so Book.EPUB / comic.CBZ on a NAS or WebDAV share is silently skipped during sync and never appears in the library.
  • apps/web/src/common/FileTreeView/LazyTreeView.tsx:195, apps/web/src/plugins/webdav/upload/FileBrowseStep.tsx:25, and apps/web/src/plugins/synology-drive/browsing/tree.ts:16 grey out / hide those files in the pickers.

Case-insensitivity is the established contract elsewhere: the sibling isPotentialZipFile in the same file lowercases the name first, and the archive reader's own getExtension lowercases too.

The fix

Lowercase the extracted extension in isFileSupported before comparing. getUrlExtension itself is left untouched (its casing is preserved for other consumers).

Verification

  • New regression test packages/shared/src/contentType.test.ts: MyBook.EPUB / comic.CBZ / document.Pdf fail on the previous code and pass after the fix; lowercase, mime-type, and unsupported-file cases covered.
  • npm run lint, npm run build (all 7 projects), shared suite 102/102 pass.
  • npm run test at repo root: only pre-existing failures remain — the same 15 tests in apps/web/src/http/HttpClientApi.web.test.ts + httpClientApi.sw.test.ts fail identically on a clean develop checkout (re-verified with this change stashed).

Other findings (not addressed in this PR)

Confirmed by tracing the real code paths; listed so they aren't lost:

  • bulkDelete omits _rev, so dangling-link deletion silently never deletes (apps/api/src/lib/couch/bulkDelete.ts): it posts {_id, _deleted: true} without _rev to _bulk_docs; CouchDB answers each doc with a conflict error (nano's bulk doesn't throw on per-doc errors), so deleteDanglingLinks records the link as deleted in the sync report while the doc survives. Dangling links accumulate and are "re-deleted" on every sync. The caller has _rev in hand (Helpers["find"] returns it) — the fix is to forward it.
  • useCollections' isNotInterested: "only" branch intersects the wrong array (apps/web/src/collections/useCollections.ts:152): it uses bookIds (the query param, undefined for the search screen caller) instead of the notInterestedBookIds computed just above — and intersection(collection.books, undefined) degenerates to the full book list. Setting the search filter "Show not interested contents: Only" therefore shows every non-empty collection instead of only collections with a not-interested book.
  • Empty Google metadata guard is always false (apps/api/src/lib/metadata/google/getGoogleBookMetadata.ts:98): if (!Object.keys(parsedMetadata)) return undefined — an array is always truthy (missing .length), so a no-result lookup still emits a field-less { type: "googleBookApi" } source entry onto the book.
  • createThrottler never clears its setInterval (apps/api/src/lib/utils.ts:97): each dropbox/google/one-drive sync spawns a 50 ms interval that lives for the whole process lifetime — a slow timer/CPU leak on a long-running server.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ


Generated by Claude Code

isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
obokuReadyReadyPreview, CommentJul 21, 2026 12:28pm

@mbret
mbret merged commit ba9dcda into developJul 27, 2026
4 checks passed
@mbret
mbret deleted the fix/bug-hunt-2026-07-21 branch July 27, 2026 12:18
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 5, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(api): consolidate synology-drive link session resolution (#526)
getFileMetadata, getFolderMetadata and download each repeated the same
~18-line preamble: validate connectorId/credentials/db, load the
connector, open a Synology Drive session and read fileId from the link.
Extract it into a single openSynologyDriveSessionForLink helper so the
validation and error handling live in one place.
Claude-Session: https://claude.ai/code/session_01SiouS4DW4vVoe9wfNAN761
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute search title once and hoist search regex in book search (#525)
useBooksForSearch recomputes on every keystroke over the whole library.
It compiled a fresh RegExp inside the .filter callback (once per book)
and called the expensive getMetadataFromBook twice inside the sort
comparator (~2·n·log(n) metadata merges). Hoist the regex to one
compilation per search and precompute each book's title once (n calls)
via decorate-sort-undecorate. Results and order are unchanged.
Benchmark at 3000 books: 149ms -> 15.5ms per search (~9.6x).
Claude-Session: https://claude.ai/code/session_01WTpoRzFz6L5YQ1MXtHE2Vf
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump next to 16.2.12 to fix DoS/SSRF/proxy-bypass advisories (#527)
Updates next and its lockstep companion eslint-config-next from 16.1.6 to
16.2.12 in @oboku/landing. Minor bump within major 16; patches ~9 high
severity advisories in the internet-facing landing app (Server Components
DoS, SSRF via WebSocket upgrades, App/Pages Router middleware & proxy
bypasses, i18n bypass).
Both were exact-pinned to 16.1.6; kept exact-pin style and moved them
together since eslint-config-next version-tracks next.
Claude-Session: https://claude.ai/code/session_01W73Y7fJypdhxKPp6YtHi65
Co-authored-by: Claude <noreply@anthropic.com>
* feat: ugprade vercel
* feat: upgrade
* chore(deps): bump sharp to 0.35.3 to fix libvips security advisory (#540)
Updates @oboku/api's sharp dependency from ^0.34.5 to ^0.35.3 to remediate
the high-severity advisory affecting sharp <0.35.0 (inherited libvips CVEs).
sharp 0.35 raises the minimum Node.js to >=20.9.0, satisfied by the pinned
Node 25 runtime. The image pipeline in covers.service.ts uses only stable
APIs (resize, toFormat, jpeg, webp, toBuffer), so no code changes are needed.
Claude-Session: https://claude.ai/code/session_014kuujQqjWVajzvvhrmqyuB
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate duplicated plugin link-info hooks (#539)
The dropbox, google, synology-drive and one-drive plugins each shipped a
byte-identical `useLinkInfo` that derives a `ID: <fileId>` label, and the
server and webdav plugins shipped an identical `filePath`-based one. Move
both implementations into `plugins/common/linkInfo.ts` as
`useFileIdLinkInfo` and `useFilePathLinkInfo`, and point each plugin at the
shared export. Behavior is unchanged; a plugin can still diverge later by
providing its own implementation.
Claude-Session: https://claude.ai/code/session_01KNBTACP3nnNjfsGvFh8i9z
Co-authored-by: Claude <noreply@anthropic.com>
* chore: remove strict dead code (unused exports) (#537)
Remove exports that knip flagged and that a repo-wide grep confirmed are
never imported or referenced anywhere (each appears only at its own
declaration; no dynamic/string/test references):
- apps/web/src/tags/helpers.ts: getProtectedTags, getTagsByIds (both
@deprecated), useTagsByIds
- apps/web/src/reader/states.ts: reader$, usePagination
- apps/web/src/collections/dbHelpers.ts: getCollections
- apps/api/src/lib/utils.ts: switchMapMergeOuter
- apps/api/src/lib/google/googleBooksApi.ts: findSeriesByTitle
- apps/api/src/lib/couch/dbHelpers.ts: findAllDataSources
Also drop the now-unused type imports left behind (Database, MangoResponse,
DataSourceDocType) and the rxjs/reactjrx imports that only those symbols used.
Claude-Session: https://claude.ai/code/session_013Xb8Rr8xPgnjmexMzNbZBW
Co-authored-by: Claude <noreply@anthropic.com>
* perf(web): linearize covers cache cleanup passes (#538)
The service-worker covers cache cleanup runs every 10 minutes (and on
startup) and had two super-linear passes over the cover cache:
- obsolete-cover detection matched each cache key against the book and
collection lists with `.some` (O(cacheKeys * (books + collections)))
- outdated-version detection compared every cache key against every
other key, re-parsing request headers each time (O(cacheKeys^2))
Replace the membership scan with a Set lookup and the pairwise scan with
a single max-time-per-coverId map. Both passes now parse each request's
headers once and produce byte-for-byte identical removal sets.
Claude-Session: https://claude.ai/code/session_01RNbzoWKvYxCqMT7PLKPTHQ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: single product version, automatic releases on master, versioned docker tags (#531)
* chore: align license metadata with GPL-3.0
The root LICENCE file is the full GNU GPL v3 text, but the README and
package manifests advertised MIT (and UNLICENSED for the API).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: publish a GitHub release when the root version changes
Tags were pushed by the web version bump, but no GitHub release was ever
created, so the repository exposed no installable version to pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: tag docker images with the version and commit sha
Images were published as :latest only, so an instance could not be pinned
to a known build. Version resolution moves to a shared job now that the
docker builds and the release both need it.
Release notes rewrite changelog links relative to the changelog into blob
URLs at the release tag, since relative targets do not resolve on a
release page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: single product version for the whole repo
The web app carried its own version line, bumped on every master push,
which was accidental rather than intended. Root package.json is now the
only version: the web app reads it through the __APP_VERSION__ build
constant, and the private unpublished manifests no longer declare one.
The persisted query cache moves to __BUILD_ID__ so it keeps busting per
build; keying it on a version that now changes only per release would let
a newer build rehydrate state persisted by an older one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* docs: document the release-notes script contract
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: mark workspace packages private and drop their versions
@oboku/shared was last published in 2022 and no publish flow exists, so
these are internal workspace packages like the apps: no version to carry,
and private stops an accidental npm publish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: bump the version automatically on every master push
The release version is now derived from conventional commits since the
last released tag (breaking -> major, feat -> minor, otherwise patch) and
committed back to master, so merging develop is the only manual step.
Downstream jobs build from the bump commit so the web app embeds the
version it ships under. A hand-edited, not-yet-released version in
package.json still wins, which also covers the pending 1.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* fix: tighten the breaking-change footer detection
The conventional commits spec allows both BREAKING CHANGE and
BREAKING-CHANGE as footer tokens, and requiring the colon stops prose
mentions from triggering a major bump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: replace hand-rolled version bump and release with semantic-release
The bump computation, tagging, release creation and notes generation move
to semantic-release with the conventionalcommits preset. A catch-all
releaseRules entry keeps the release-on-every-master-push behavior that
the default rules would skip for chore/docs-only pushes.
Upgrade instructions now travel in BREAKING CHANGE commit footers, which
release-notes-generator renders on the release page, so the gitbook
changelog becomes a pointer to GitHub releases and both custom release
scripts are deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.0.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@mbret@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(shared): match reader file extensions case-insensitively - #508

Merged
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21
Jul 27, 2026
Merged

fix(shared): match reader file extensions case-insensitively#508
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21

Conversation

@mbret

Copy link
Copy Markdown
Owner

The bug

isFileSupported (packages/shared/src/contentType.ts) builds the extension from the raw file name and compares it against the all-lowercase READER_ACCEPTED_EXTENSIONS list. Any file with an uppercase or mixed-case extension is reported as unsupported:

  • isFileSupported({ name: "MyBook.EPUB" })false (verified by executing the real code; "MyBook.epub"true)

How to observe it

isFileSupported is the gate for which files become books:

  • apps/api/src/plugins/synology-drive/client.ts:92 and apps/api/src/plugins/webdav/operations.ts:48 filter sync items with it (name only, no mime type available), so Book.EPUB / comic.CBZ on a NAS or WebDAV share is silently skipped during sync and never appears in the library.
  • apps/web/src/common/FileTreeView/LazyTreeView.tsx:195, apps/web/src/plugins/webdav/upload/FileBrowseStep.tsx:25, and apps/web/src/plugins/synology-drive/browsing/tree.ts:16 grey out / hide those files in the pickers.

Case-insensitivity is the established contract elsewhere: the sibling isPotentialZipFile in the same file lowercases the name first, and the archive reader's own getExtension lowercases too.

The fix

Lowercase the extracted extension in isFileSupported before comparing. getUrlExtension itself is left untouched (its casing is preserved for other consumers).

Verification

  • New regression test packages/shared/src/contentType.test.ts: MyBook.EPUB / comic.CBZ / document.Pdf fail on the previous code and pass after the fix; lowercase, mime-type, and unsupported-file cases covered.
  • npm run lint, npm run build (all 7 projects), shared suite 102/102 pass.
  • npm run test at repo root: only pre-existing failures remain — the same 15 tests in apps/web/src/http/HttpClientApi.web.test.ts + httpClientApi.sw.test.ts fail identically on a clean develop checkout (re-verified with this change stashed).

Other findings (not addressed in this PR)

Confirmed by tracing the real code paths; listed so they aren't lost:

  • bulkDelete omits _rev, so dangling-link deletion silently never deletes (apps/api/src/lib/couch/bulkDelete.ts): it posts {_id, _deleted: true} without _rev to _bulk_docs; CouchDB answers each doc with a conflict error (nano's bulk doesn't throw on per-doc errors), so deleteDanglingLinks records the link as deleted in the sync report while the doc survives. Dangling links accumulate and are "re-deleted" on every sync. The caller has _rev in hand (Helpers["find"] returns it) — the fix is to forward it.
  • useCollections' isNotInterested: "only" branch intersects the wrong array (apps/web/src/collections/useCollections.ts:152): it uses bookIds (the query param, undefined for the search screen caller) instead of the notInterestedBookIds computed just above — and intersection(collection.books, undefined) degenerates to the full book list. Setting the search filter "Show not interested contents: Only" therefore shows every non-empty collection instead of only collections with a not-interested book.
  • Empty Google metadata guard is always false (apps/api/src/lib/metadata/google/getGoogleBookMetadata.ts:98): if (!Object.keys(parsedMetadata)) return undefined — an array is always truthy (missing .length), so a no-result lookup still emits a field-less { type: "googleBookApi" } source entry onto the book.
  • createThrottler never clears its setInterval (apps/api/src/lib/utils.ts:97): each dropbox/google/one-drive sync spawns a 50 ms interval that lives for the whole process lifetime — a slow timer/CPU leak on a long-running server.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ


Generated by Claude Code

isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
obokuReadyReadyPreview, CommentJul 21, 2026 12:28pm

@mbret
mbret merged commit ba9dcda into developJul 27, 2026
4 checks passed
@mbret
mbret deleted the fix/bug-hunt-2026-07-21 branch July 27, 2026 12:18
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 5, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(api): consolidate synology-drive link session resolution (#526)
getFileMetadata, getFolderMetadata and download each repeated the same
~18-line preamble: validate connectorId/credentials/db, load the
connector, open a Synology Drive session and read fileId from the link.
Extract it into a single openSynologyDriveSessionForLink helper so the
validation and error handling live in one place.
Claude-Session: https://claude.ai/code/session_01SiouS4DW4vVoe9wfNAN761
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute search title once and hoist search regex in book search (#525)
useBooksForSearch recomputes on every keystroke over the whole library.
It compiled a fresh RegExp inside the .filter callback (once per book)
and called the expensive getMetadataFromBook twice inside the sort
comparator (~2·n·log(n) metadata merges). Hoist the regex to one
compilation per search and precompute each book's title once (n calls)
via decorate-sort-undecorate. Results and order are unchanged.
Benchmark at 3000 books: 149ms -> 15.5ms per search (~9.6x).
Claude-Session: https://claude.ai/code/session_01WTpoRzFz6L5YQ1MXtHE2Vf
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump next to 16.2.12 to fix DoS/SSRF/proxy-bypass advisories (#527)
Updates next and its lockstep companion eslint-config-next from 16.1.6 to
16.2.12 in @oboku/landing. Minor bump within major 16; patches ~9 high
severity advisories in the internet-facing landing app (Server Components
DoS, SSRF via WebSocket upgrades, App/Pages Router middleware & proxy
bypasses, i18n bypass).
Both were exact-pinned to 16.1.6; kept exact-pin style and moved them
together since eslint-config-next version-tracks next.
Claude-Session: https://claude.ai/code/session_01W73Y7fJypdhxKPp6YtHi65
Co-authored-by: Claude <noreply@anthropic.com>
* feat: ugprade vercel
* feat: upgrade
* chore(deps): bump sharp to 0.35.3 to fix libvips security advisory (#540)
Updates @oboku/api's sharp dependency from ^0.34.5 to ^0.35.3 to remediate
the high-severity advisory affecting sharp <0.35.0 (inherited libvips CVEs).
sharp 0.35 raises the minimum Node.js to >=20.9.0, satisfied by the pinned
Node 25 runtime. The image pipeline in covers.service.ts uses only stable
APIs (resize, toFormat, jpeg, webp, toBuffer), so no code changes are needed.
Claude-Session: https://claude.ai/code/session_014kuujQqjWVajzvvhrmqyuB
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate duplicated plugin link-info hooks (#539)
The dropbox, google, synology-drive and one-drive plugins each shipped a
byte-identical `useLinkInfo` that derives a `ID: <fileId>` label, and the
server and webdav plugins shipped an identical `filePath`-based one. Move
both implementations into `plugins/common/linkInfo.ts` as
`useFileIdLinkInfo` and `useFilePathLinkInfo`, and point each plugin at the
shared export. Behavior is unchanged; a plugin can still diverge later by
providing its own implementation.
Claude-Session: https://claude.ai/code/session_01KNBTACP3nnNjfsGvFh8i9z
Co-authored-by: Claude <noreply@anthropic.com>
* chore: remove strict dead code (unused exports) (#537)
Remove exports that knip flagged and that a repo-wide grep confirmed are
never imported or referenced anywhere (each appears only at its own
declaration; no dynamic/string/test references):
- apps/web/src/tags/helpers.ts: getProtectedTags, getTagsByIds (both
@deprecated), useTagsByIds
- apps/web/src/reader/states.ts: reader$, usePagination
- apps/web/src/collections/dbHelpers.ts: getCollections
- apps/api/src/lib/utils.ts: switchMapMergeOuter
- apps/api/src/lib/google/googleBooksApi.ts: findSeriesByTitle
- apps/api/src/lib/couch/dbHelpers.ts: findAllDataSources
Also drop the now-unused type imports left behind (Database, MangoResponse,
DataSourceDocType) and the rxjs/reactjrx imports that only those symbols used.
Claude-Session: https://claude.ai/code/session_013Xb8Rr8xPgnjmexMzNbZBW
Co-authored-by: Claude <noreply@anthropic.com>
* perf(web): linearize covers cache cleanup passes (#538)
The service-worker covers cache cleanup runs every 10 minutes (and on
startup) and had two super-linear passes over the cover cache:
- obsolete-cover detection matched each cache key against the book and
collection lists with `.some` (O(cacheKeys * (books + collections)))
- outdated-version detection compared every cache key against every
other key, re-parsing request headers each time (O(cacheKeys^2))
Replace the membership scan with a Set lookup and the pairwise scan with
a single max-time-per-coverId map. Both passes now parse each request's
headers once and produce byte-for-byte identical removal sets.
Claude-Session: https://claude.ai/code/session_01RNbzoWKvYxCqMT7PLKPTHQ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: single product version, automatic releases on master, versioned docker tags (#531)
* chore: align license metadata with GPL-3.0
The root LICENCE file is the full GNU GPL v3 text, but the README and
package manifests advertised MIT (and UNLICENSED for the API).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: publish a GitHub release when the root version changes
Tags were pushed by the web version bump, but no GitHub release was ever
created, so the repository exposed no installable version to pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: tag docker images with the version and commit sha
Images were published as :latest only, so an instance could not be pinned
to a known build. Version resolution moves to a shared job now that the
docker builds and the release both need it.
Release notes rewrite changelog links relative to the changelog into blob
URLs at the release tag, since relative targets do not resolve on a
release page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: single product version for the whole repo
The web app carried its own version line, bumped on every master push,
which was accidental rather than intended. Root package.json is now the
only version: the web app reads it through the __APP_VERSION__ build
constant, and the private unpublished manifests no longer declare one.
The persisted query cache moves to __BUILD_ID__ so it keeps busting per
build; keying it on a version that now changes only per release would let
a newer build rehydrate state persisted by an older one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* docs: document the release-notes script contract
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: mark workspace packages private and drop their versions
@oboku/shared was last published in 2022 and no publish flow exists, so
these are internal workspace packages like the apps: no version to carry,
and private stops an accidental npm publish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: bump the version automatically on every master push
The release version is now derived from conventional commits since the
last released tag (breaking -> major, feat -> minor, otherwise patch) and
committed back to master, so merging develop is the only manual step.
Downstream jobs build from the bump commit so the web app embeds the
version it ships under. A hand-edited, not-yet-released version in
package.json still wins, which also covers the pending 1.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* fix: tighten the breaking-change footer detection
The conventional commits spec allows both BREAKING CHANGE and
BREAKING-CHANGE as footer tokens, and requiring the colon stops prose
mentions from triggering a major bump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: replace hand-rolled version bump and release with semantic-release
The bump computation, tagging, release creation and notes generation move
to semantic-release with the conventionalcommits preset. A catch-all
releaseRules entry keeps the release-on-every-master-push behavior that
the default rules would skip for chore/docs-only pushes.
Upgrade instructions now travel in BREAKING CHANGE commit footers, which
release-notes-generator renders on the release page, so the gitbook
changelog becomes a pointer to GitHub releases and both custom release
scripts are deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.0.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@mbret@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(shared): match reader file extensions case-insensitively - #508

Merged
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21
Jul 27, 2026
Merged

fix(shared): match reader file extensions case-insensitively#508
mbret merged 1 commit into
developfrom
fix/bug-hunt-2026-07-21

Conversation

@mbret

Copy link
Copy Markdown
Owner

The bug

isFileSupported (packages/shared/src/contentType.ts) builds the extension from the raw file name and compares it against the all-lowercase READER_ACCEPTED_EXTENSIONS list. Any file with an uppercase or mixed-case extension is reported as unsupported:

  • isFileSupported({ name: "MyBook.EPUB" })false (verified by executing the real code; "MyBook.epub"true)

How to observe it

isFileSupported is the gate for which files become books:

  • apps/api/src/plugins/synology-drive/client.ts:92 and apps/api/src/plugins/webdav/operations.ts:48 filter sync items with it (name only, no mime type available), so Book.EPUB / comic.CBZ on a NAS or WebDAV share is silently skipped during sync and never appears in the library.
  • apps/web/src/common/FileTreeView/LazyTreeView.tsx:195, apps/web/src/plugins/webdav/upload/FileBrowseStep.tsx:25, and apps/web/src/plugins/synology-drive/browsing/tree.ts:16 grey out / hide those files in the pickers.

Case-insensitivity is the established contract elsewhere: the sibling isPotentialZipFile in the same file lowercases the name first, and the archive reader's own getExtension lowercases too.

The fix

Lowercase the extracted extension in isFileSupported before comparing. getUrlExtension itself is left untouched (its casing is preserved for other consumers).

Verification

  • New regression test packages/shared/src/contentType.test.ts: MyBook.EPUB / comic.CBZ / document.Pdf fail on the previous code and pass after the fix; lowercase, mime-type, and unsupported-file cases covered.
  • npm run lint, npm run build (all 7 projects), shared suite 102/102 pass.
  • npm run test at repo root: only pre-existing failures remain — the same 15 tests in apps/web/src/http/HttpClientApi.web.test.ts + httpClientApi.sw.test.ts fail identically on a clean develop checkout (re-verified with this change stashed).

Other findings (not addressed in this PR)

Confirmed by tracing the real code paths; listed so they aren't lost:

  • bulkDelete omits _rev, so dangling-link deletion silently never deletes (apps/api/src/lib/couch/bulkDelete.ts): it posts {_id, _deleted: true} without _rev to _bulk_docs; CouchDB answers each doc with a conflict error (nano's bulk doesn't throw on per-doc errors), so deleteDanglingLinks records the link as deleted in the sync report while the doc survives. Dangling links accumulate and are "re-deleted" on every sync. The caller has _rev in hand (Helpers["find"] returns it) — the fix is to forward it.
  • useCollections' isNotInterested: "only" branch intersects the wrong array (apps/web/src/collections/useCollections.ts:152): it uses bookIds (the query param, undefined for the search screen caller) instead of the notInterestedBookIds computed just above — and intersection(collection.books, undefined) degenerates to the full book list. Setting the search filter "Show not interested contents: Only" therefore shows every non-empty collection instead of only collections with a not-interested book.
  • Empty Google metadata guard is always false (apps/api/src/lib/metadata/google/getGoogleBookMetadata.ts:98): if (!Object.keys(parsedMetadata)) return undefined — an array is always truthy (missing .length), so a no-result lookup still emits a field-less { type: "googleBookApi" } source entry onto the book.
  • createThrottler never clears its setInterval (apps/api/src/lib/utils.ts:97): each dropbox/google/one-drive sync spawns a 50 ms interval that lives for the whole process lifetime — a slow timer/CPU leak on a long-running server.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ


Generated by Claude Code

isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
obokuReadyReadyPreview, CommentJul 21, 2026 12:28pm

@mbret
mbret merged commit ba9dcda into developJul 27, 2026
4 checks passed
@mbret
mbret deleted the fix/bug-hunt-2026-07-21 branch July 27, 2026 12:18
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit that referenced this pull request Aug 5, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)
* feat: upgrade @prose-reader/* to 1.332.0
Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.
Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
and mount(containerElement) is a one-shot DOM attachment; reader.load()
is gone. useCreateReader now creates + mounts + destroys the reader in a
single effect (destroy() is the true inverse of create + mount, so the
effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
progress writes flowing back into the book query never destroy/recreate
the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
loading overlay now keys off !mounted.
Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
$$typeof on every object, throwing SecurityError on our gapi cross-origin
iframe (react/react#34840). The uncaught
throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
the work loop (crash on back navigation). Prod is unaffected; dev is
unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): read book once via enabled gate instead of useLiveRef
The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: unpin apps/web react and bump react to 19.2.7 project-wide
apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.
Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)
Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:
- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads
Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5
Co-authored-by: Claude <noreply@anthropic.com>
* fix: lock
* fix: biome
* fix: types
* feat: migrate to pnpm
* fix: dedupe
* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames
React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.
Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer
Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): bootstrap pnpm 11 in install command to match lockfile
Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(vercel): invoke bootstrapped pnpm by absolute path
Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): copy patches into image for frozen install
pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(vercel): set node 24 via engines for web and landing
engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api): declare express as a direct dependency
main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(landing): ignore vercel cli .env.local
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): freeze first book result to keep reader mounted
useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): derive go-back availability from the router history index (#514)
The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.
react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(web): generalize first-result snapshot into useEnsureQueryData$
Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism
Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): memoize useEnsureQueryData$ snapshot in a private query
Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): default useEnsureQueryData$ to networkMode always
The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): let users protect storage from browser eviction (#516)
Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.
- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it
The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.
`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(shared): match reader file extensions case-insensitively (#508)
isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.
Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: consolidate duplicated web plugin helpers (#506)
Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.
Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate incremental book mutation pipeline (#509)
The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.
Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.
Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): make the not-interested-only filter match not interested books (#522)
* fix(web): make the not-interested-only filter match not interested books
The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ
* refactor(web): colocate not-interested filter predicate in useCollections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)
rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.
Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute book title sort key once per book in alpha sort (#510)
The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.
Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav
Co-authored-by: Claude <noreply@anthropic.com>
* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)
* chore(deps): upgrade workspace dependencies
Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.
dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.
The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
* refactor(archive-metadata): move the metadata writer out of the src root
`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(docker): copy scripts into the install stage (#528)
The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.
Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(api): consolidate synology-drive link session resolution (#526)
getFileMetadata, getFolderMetadata and download each repeated the same
~18-line preamble: validate connectorId/credentials/db, load the
connector, open a Synology Drive session and read fileId from the link.
Extract it into a single openSynologyDriveSessionForLink helper so the
validation and error handling live in one place.
Claude-Session: https://claude.ai/code/session_01SiouS4DW4vVoe9wfNAN761
Co-authored-by: Claude <noreply@anthropic.com>
* perf: compute search title once and hoist search regex in book search (#525)
useBooksForSearch recomputes on every keystroke over the whole library.
It compiled a fresh RegExp inside the .filter callback (once per book)
and called the expensive getMetadataFromBook twice inside the sort
comparator (~2·n·log(n) metadata merges). Hoist the regex to one
compilation per search and precompute each book's title once (n calls)
via decorate-sort-undecorate. Results and order are unchanged.
Benchmark at 3000 books: 149ms -> 15.5ms per search (~9.6x).
Claude-Session: https://claude.ai/code/session_01WTpoRzFz6L5YQ1MXtHE2Vf
Co-authored-by: Claude <noreply@anthropic.com>
* chore(deps): bump next to 16.2.12 to fix DoS/SSRF/proxy-bypass advisories (#527)
Updates next and its lockstep companion eslint-config-next from 16.1.6 to
16.2.12 in @oboku/landing. Minor bump within major 16; patches ~9 high
severity advisories in the internet-facing landing app (Server Components
DoS, SSRF via WebSocket upgrades, App/Pages Router middleware & proxy
bypasses, i18n bypass).
Both were exact-pinned to 16.1.6; kept exact-pin style and moved them
together since eslint-config-next version-tracks next.
Claude-Session: https://claude.ai/code/session_01W73Y7fJypdhxKPp6YtHi65
Co-authored-by: Claude <noreply@anthropic.com>
* feat: ugprade vercel
* feat: upgrade
* chore(deps): bump sharp to 0.35.3 to fix libvips security advisory (#540)
Updates @oboku/api's sharp dependency from ^0.34.5 to ^0.35.3 to remediate
the high-severity advisory affecting sharp <0.35.0 (inherited libvips CVEs).
sharp 0.35 raises the minimum Node.js to >=20.9.0, satisfied by the pinned
Node 25 runtime. The image pipeline in covers.service.ts uses only stable
APIs (resize, toFormat, jpeg, webp, toBuffer), so no code changes are needed.
Claude-Session: https://claude.ai/code/session_014kuujQqjWVajzvvhrmqyuB
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(web): consolidate duplicated plugin link-info hooks (#539)
The dropbox, google, synology-drive and one-drive plugins each shipped a
byte-identical `useLinkInfo` that derives a `ID: <fileId>` label, and the
server and webdav plugins shipped an identical `filePath`-based one. Move
both implementations into `plugins/common/linkInfo.ts` as
`useFileIdLinkInfo` and `useFilePathLinkInfo`, and point each plugin at the
shared export. Behavior is unchanged; a plugin can still diverge later by
providing its own implementation.
Claude-Session: https://claude.ai/code/session_01KNBTACP3nnNjfsGvFh8i9z
Co-authored-by: Claude <noreply@anthropic.com>
* chore: remove strict dead code (unused exports) (#537)
Remove exports that knip flagged and that a repo-wide grep confirmed are
never imported or referenced anywhere (each appears only at its own
declaration; no dynamic/string/test references):
- apps/web/src/tags/helpers.ts: getProtectedTags, getTagsByIds (both
@deprecated), useTagsByIds
- apps/web/src/reader/states.ts: reader$, usePagination
- apps/web/src/collections/dbHelpers.ts: getCollections
- apps/api/src/lib/utils.ts: switchMapMergeOuter
- apps/api/src/lib/google/googleBooksApi.ts: findSeriesByTitle
- apps/api/src/lib/couch/dbHelpers.ts: findAllDataSources
Also drop the now-unused type imports left behind (Database, MangoResponse,
DataSourceDocType) and the rxjs/reactjrx imports that only those symbols used.
Claude-Session: https://claude.ai/code/session_013Xb8Rr8xPgnjmexMzNbZBW
Co-authored-by: Claude <noreply@anthropic.com>
* perf(web): linearize covers cache cleanup passes (#538)
The service-worker covers cache cleanup runs every 10 minutes (and on
startup) and had two super-linear passes over the cover cache:
- obsolete-cover detection matched each cache key against the book and
collection lists with `.some` (O(cacheKeys * (books + collections)))
- outdated-version detection compared every cache key against every
other key, re-parsing request headers each time (O(cacheKeys^2))
Replace the membership scan with a Set lookup and the pairwise scan with
a single max-time-per-coverId map. Both passes now parse each request's
headers once and produce byte-for-byte identical removal sets.
Claude-Session: https://claude.ai/code/session_01RNbzoWKvYxCqMT7PLKPTHQ
Co-authored-by: Claude <noreply@anthropic.com>
* refactor: single product version, automatic releases on master, versioned docker tags (#531)
* chore: align license metadata with GPL-3.0
The root LICENCE file is the full GNU GPL v3 text, but the README and
package manifests advertised MIT (and UNLICENSED for the API).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: publish a GitHub release when the root version changes
Tags were pushed by the web version bump, but no GitHub release was ever
created, so the repository exposed no installable version to pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: tag docker images with the version and commit sha
Images were published as :latest only, so an instance could not be pinned
to a known build. Version resolution moves to a shared job now that the
docker builds and the release both need it.
Release notes rewrite changelog links relative to the changelog into blob
URLs at the release tag, since relative targets do not resolve on a
release page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: single product version for the whole repo
The web app carried its own version line, bumped on every master push,
which was accidental rather than intended. Root package.json is now the
only version: the web app reads it through the __APP_VERSION__ build
constant, and the private unpublished manifests no longer declare one.
The persisted query cache moves to __BUILD_ID__ so it keeps busting per
build; keying it on a version that now changes only per release would let
a newer build rehydrate state persisted by an older one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* docs: document the release-notes script contract
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* refactor: mark workspace packages private and drop their versions
@oboku/shared was last published in 2022 and no publish flow exists, so
these are internal workspace packages like the apps: no version to carry,
and private stops an accidental npm publish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: bump the version automatically on every master push
The release version is now derived from conventional commits since the
last released tag (breaking -> major, feat -> minor, otherwise patch) and
committed back to master, so merging develop is the only manual step.
Downstream jobs build from the bump commit so the web app embeds the
version it ships under. A hand-edited, not-yet-released version in
package.json still wins, which also covers the pending 1.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* fix: tighten the breaking-change footer detection
The conventional commits spec allows both BREAKING CHANGE and
BREAKING-CHANGE as footer tokens, and requiring the colon stops prose
mentions from triggering a major bump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
* ci: replace hand-rolled version bump and release with semantic-release
The bump computation, tagging, release creation and notes generation move
to semantic-release with the conventionalcommits preset. A catch-all
releaseRules entry keeps the release-on-every-master-push behavior that
the default rules would skip for chore/docs-only pushes.
Upgrade instructions now travel in BREAKING CHANGE commit footers, which
release-notes-generator renders on the release page, so the gitbook
changelog becomes a pointer to GitHub releases and both custom release
scripts are deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa
---------
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.0.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@mbret@claude