` with `display: inline-block` (`.class_s5mz1`). Atomic inline-level boxes (inline-block / inline-flex / inline-grid / inline-table) **cannot fragment across CSS columns**, so in paginated (columnized) mode the 7700px-tall box overflows the page vertically and every column past the first is clipped → "1 page left in chapter" while most of the chapter is unreachable. Direct `goTo({index})` and forward `next()` both still RENDER the section (engine traverses by content), so the symptom only manifests as clipped/unreachable pages + bogus page counts; scrolled mode is unaffected (vertical overflow is normal there).
+
+Diagnosis tell: in column mode `documentElement.scrollHeight >> clientHeight` (e.g. 7768 vs 632); late headings stack vertically at one far-right column-left offset instead of spreading across columns.
+
+Fix: `packages/foliate-js/paginator.js` → `#demoteUnfragmentableBoxes(availableHeight)`, called from `columnize()` after `setImageSize` (column-mode only). Guarded fast-path: returns immediately unless `scrollHeight > clientHeight + 1`. When overflowing, scans `body.querySelectorAll('*')`, and for any element whose computed display is atomic-inline AND `getBoundingClientRect().height > availableHeight`, demotes to the fragmentable equivalent (inline-block→block, inline-flex→flex, inline-grid→grid, inline-table→table) via `setStylesImportant`. Idempotent (demoted elements no longer match), regression-free (short legit inline-blocks like side-by-side figures untouched — they're never page-tall). Mirrors the existing `setImageSize` over-tall-image clamp and the `p { display: block }` rule in `style.ts` ("epubs set insane inline-block for p").
+
+Test: `src/__tests__/document/paginator-inline-block-overflow.browser.test.ts` + fixture `repro-inline-block-overflow.epub` (one chapter wrapped in `.wrap{display:inline-block}`, 50 paras + TAIL_MARKER). Asserts `scrollHeight <= clientHeight+2`, wrap display `block`, tail heading in a later column within page height. Needs real layout → browser test (jsdom has no layout). Dev server (Next/Turbopack) picks up the workspace foliate-js edit on reload. Related: [[paginator-gutter-bleed-asymmetry-4394]].
diff --git a/apps/readest-app/.claude/memory/ios-instant-dict-double-popup.md b/apps/readest-app/.claude/memory/ios-instant-dict-double-popup.md
new file mode 100644
index 00000000..ef709c07
--- /dev/null
+++ b/apps/readest-app/.claude/memory/ios-instant-dict-double-popup.md
@@ -0,0 +1,18 @@
+---
+name: ios-instant-dict-double-popup
+description: iOS instant system-dictionary fired 2-3× per long-press + tap-to-deselect re-opened it + Word Lens ignored system dict; deferredAction once-per-gesture latch + long-press-hold gate
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: 1c09f918-0b1d-4f75-b1c6-8cef5eb73d60
+---
+
+Three related instant-dictionary bugs fixed together (dev branch, 2026-06-18). Core: the **instant quick action** (Annotator effect `[selection,bookKey]` → `handleQuickAction`) fired per `selectionchange`, and **iOS emits MULTIPLE `selectionchange` for one long-press** (user log showed 3; Android emits 1). Each fire → `handleDictionary` (system path) → `invokeSystemDictionary` → native `show_lookup_popover` which drills to the top-most presented VC and stacks another `UIReferenceLibraryViewController` → 2-3 sheets. Android never hit it because it **defers the action to `touchend`** (coalesces); iOS fired immediately.
+
+**Fix 1 (double/triple sheet)** — `src/app/reader/utils/deferredAction.ts`: added a `fired` latch so `runOrDeferAction`/`flushDeferredAction` run the action **at most once per gesture**; `beginGesture(state)` (clears pending + re-arms) called at gesture start — Android **native** `touchstart` (replaced `cancelDeferredAction`) and a NEW **non-Android DOM `pointerdown`** listener in `Annotator.tsx` (gated `!isAndroidApp`; Android keeps its native path).
+
+**Fix 2 (tap-to-deselect re-opened dict ~1/3)** — after dismissing the sheet iOS leaves the word selected; the reselection is safe (latch still set, no WebView pointerdown from the modal swipe), but tapping outside to deselect IS a pointerdown → `beginGesture` re-armed the latch, then a racy `selectionchange` re-reported the lingering word before collapse → re-fired. Fix = `isLongPressHold(pointerDownTime, now, 300ms)` gate in `handleQuickAction` (gated `!isAndroidApp`): only fire from a long-press hold (iOS selection appears ~500ms after pointerdown; tap-stray fires ~tens of ms). Touch pointerdown time recorded in the non-Android listener; **mouse records 0 → bypasses the gate** (desktop selects on pointerup).
+
+**Fix 3 (Word Lens ignored system dict)** — Annotator effect `wantWordLensDict` branch hardcoded `setShowDictionaryPopup(true)`; changed to call `handleDictionary()` (which checks `isSystemDictionaryEnabled` → `invokeSystemDictionary`, else in-app popup), same as the toolbar/instant paths. See [[wordlens-feature]].
+
+Verified on Xiaomi 13 Pro via CDP+adb (`src/__tests__/android/helpers/*`): real system-dict path on Android = `ACTION_PROCESS_TEXT`→Eudic; instant dict fired once/long-press + re-armed gesture 2; gloss tap → `handleDictionary system=true`+`invokeSystemDictionary os=android`. iOS confirmed by user. Gotchas: `longPressWord` waits for a persistent selection → times out in instant-action mode (the action dismisses the selection); count fires via a `console.info` hook read over CDP `evaluate` instead. `openFixtureBook`'s >200-char gate fails when the fixture's saved progress lands on the sparse feedbooks end-page → connect to the already-open reader instead.
diff --git a/apps/readest-app/.claude/memory/rsvp-rtl-word-display-4630.md b/apps/readest-app/.claude/memory/rsvp-rtl-word-display-4630.md
new file mode 100644
index 00000000..7207951d
--- /dev/null
+++ b/apps/readest-app/.claude/memory/rsvp-rtl-word-display-4630.md
@@ -0,0 +1,16 @@
+---
+name: rsvp-rtl-word-display-4630
+description: RSVP ORP focus-letter split breaks Arabic/RTL shaping; render RTL words whole with dir=rtl
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: 0561d60a-5d21-4b58-8bc9-1295a9f768ce
+---
+
+#4630: In RSVP the word window showed Arabic with letters separated, LTR, wrong order (e.g. علم → ل م ع), sometimes a tofu box. Root cause = the ORP focus-letter layout in `RSVPOverlay.tsx` slices each word into `wordBefore`/`orpChar`/`wordAfter` by character index and lays them out in absolutely-positioned LTR spans. Slicing by index breaks Arabic letter shaping (letters stop connecting → isolated/notdef forms) and the before→after LTR layout reverses visual order. The context panel renders each word as ONE unsplit `
`, which is why the reporter saw it render correctly there.
+
+Fix: detect RTL and render the word whole, reusing the existing CJK "Highlight Word" `.rsvp-word-whole` branch, with `dir='rtl'` for correct base direction.
+- New `isRTLText(text)` in `src/services/rsvp/utils.ts` — `RTL_PATTERN = /[-ࣿיִ-﷿ﹰ-]/` (Hebrew/Arabic/Syriac/Thaana/NKo/Samaritan/Mandaic + presentation forms). Mind the literal-char Edit pitfall: bidi reordering made exact-string Edit fail; wrote the regex with `\u` escapes via perl on the line number instead.
+- Overlay branch: `isRTLWord || (isCJKWord && highlightWholeWord)` → whole-word span; `dir={isRTLWord ? 'rtl' : undefined}`. No new toggle — RTL always renders whole (ORP anchoring is meaningless for unsplittable shaped scripts).
+
+General lesson: any complex-shaping/bidi script can't survive per-character span splitting; the same trap applies to other features that slice words by index for highlighting. Related: [[rsvp-font-settings-4519]]. jsdom tests assert DOM structure (whole span + dir) only; glyph shaping is a real-browser concern but is guaranteed correct because the sibling context span already shapes correctly.
diff --git a/apps/readest-app/.claude/memory/security-advisories-web-2026-06.md b/apps/readest-app/.claude/memory/security-advisories-web-2026-06.md
new file mode 100644
index 00000000..1ba779f7
--- /dev/null
+++ b/apps/readest-app/.claude/memory/security-advisories-web-2026-06.md
@@ -0,0 +1,26 @@
+---
+name: security-advisories-web-2026-06
+description: "GHSA advisory fixes (OPDS SSRF, storage traversal, Stripe hijack, Tauri IPC scoping) — PRs #4638 (web) + #4639 (native); canonical isBlockedHost location"
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: 552008b6-251e-478c-91f3-f05526537157
+---
+
+Five GitHub security advisories filed against readest (2026-06-06/09), all `triage` when worked on 2026-06-18. Four distinct issues (GHSA-c7mm & GHSA-5g3f are the same OPDS-proxy SSRF). ALL FOUR FIXED across two PRs (base `main`):
+- **PR #4638** `fix/security-advisories-web` — web/server: A (OPDS SSRF + kosync isLanAddress), B (storage), D (Stripe).
+- **PR #4639** `fix/security-tauri-transfer-scope` — native: C (Tauri transfer_file scoping).
+
+After both merge: comment on each GHSA noting the fixing PR (pending merge). Note: a stray `git checkout dev` happened during the first lint-staged commit (commit landed on dev, moved back to feature branch via `git branch -f`) — double-check `git branch --show-current` before committing here.
+
+**Fixed (A, B, D):**
+- **A — OPDS proxy SSRF** (`src/app/api/opds/proxy/route.ts`): added scheme allowlist + `isBlockedHost` + manual per-hop redirect (`fetchFollowingRedirects`/`SsrfBlockedError`). Closes the self-hosted Critical (IMDS/internal/redirect-SSRF). Reference safe pattern was `src/pages/api/send/fetch-url.ts`.
+- **A2 — canonical SSRF blocklist**: moved `isBlockedHost`/`isBlockedV4` into `src/utils/network.ts` (canonical home). `fetch-url.ts` now imports + re-exports it (keeps `send-fetch-url-guard.test.ts` import valid). Reimplemented `isLanAddress(url)` to delegate to `isBlockedHost` — fixes the kosync secondary finding (old one only blocked literal `127.0.0.1`/`0.0.0.0`, missed `127.0.0.0/8`, `198.18/15`, multicast, `.internal`/`.lan`/single-label, mapped IPv6). Safe: `new URL()` throws on invalid-octet IPs so the catch keeps `10.256.0.1`→false (existing `network.test.ts` stays green). `KOSyncClient` treats LAN as fetch-direct; `CatalogManager` only warns → stricter is safe.
+- **B — storage upload key traversal** (`src/pages/api/storage/upload.ts`): added `isSafeObjectKeyName` in `src/utils/object.ts`, guard after auth (covers temp + main). KEY CONSTRAINT: legit `fileName` DOES contain `/` (`Readest/Books/...`, `Readest/Replicas///` from `uploadReplicaFile`), so reject `..`/`.`/empty segments + leading-slash + backslash + NUL (raw & percent-decoded), NOT all slashes.
+- **D — Stripe `/check` hijack** (`src/app/api/stripe/check/route.ts`): added `if (session.metadata?.userId !== user.id) return 403` before createOrUpdate*. checkout route stamps `metadata.userId`; webhook already binds on it.
+
+**C — Tauri native (PR #4639)** GHSA-55vr-pvq5-6fmg: unscoped `download_file`/`upload_file` in `src-tauri/src/transfer_file.rs` → arbitrary local read/write. FIXED: added `app: AppHandle` param + `ensure_path_allowed` (rejects relative + `..` via `has_disallowed_components`, then `fs_scope().is_allowed()`). Chose STRICT `is_allowed` (NOT read_dir's `|| contains("Readest")` substring hatch) because all legit callers (cloud sync, WebDAV, self-updater APK→`'Cache'`, OPDS→`'Cache'`) resolve under static scope ($APPDATA/Readest, $APPCACHE, $TEMP) OR persisted dialog grants (custom root via `setCustomRootDir`→picker→`allow_paths_in_scopes`; external folders re-granted at startup; `tauri_plugin_persisted_scope` makes sticky). Clippy needed `#[allow(clippy::too_many_arguments)]` on download_file (8 args). AppHandle auto-injected → JS invoke unchanged. NOTE: shared `target/` (worktree) was polluted with a deleted sibling worktree's abs plugin-permission paths → build failed `failed to read .../readest-feat-nightly-update-channel/.../fs/permissions/app.toml`; fix = `rm -rf` the `target/debug/build/-` dirs grepping for the stale path, then rebuild. skip_ssl_verification left as-is (OPDS needs it). read_dir's own `contains("Readest")` hatch left untouched (out of scope).
+
+**Non-obvious decision:** OPDS proxy can't require Readest auth — it's consumed from the browser via `
` (covers) and `window.fetch` WITHOUT a Readest token; the `auth` query param is the *upstream* OPDS server cred, not the user token. So auth would break OPDS browsing/images. SSRF host-filter is the non-breaking high-value fix; residual relay/CORS-bypass on hosted CF (Medium) left for maintainer. On web the proxy is a CF Worker that can't reach a user LAN anyway (desktop bypasses via `needsProxy`), so blocking private hosts removes no functionality.
+
+Test invocation gotcha: `npx vitest run ` skips dotenv → `src/utils/supabase.ts:8 atob(...)` throws at import for tests that load the REAL `@/utils/access` (e.g. `send-fetch-url-guard.test.ts`). Use `pnpm test` (wraps `dotenv -e .env -e .env.test.local`) or `npx dotenv -e .env -e .env.test.local -- vitest run`. Tests that mock supabase/access are unaffected.
diff --git a/apps/readest-app/.claude/memory/share-feature.md b/apps/readest-app/.claude/memory/share-feature.md
index af177d5f..ccdfa600 100644
--- a/apps/readest-app/.claude/memory/share-feature.md
+++ b/apps/readest-app/.claude/memory/share-feature.md
@@ -51,3 +51,11 @@ See "Critical Files (modify or create)" table in the plan. Key starting points:
- Landing reference: `src/app/o/page.tsx` (lift `Card`, `BrandHeader`, `PageFooter` to `src/components/landing/`)
- Deeplink hook reference: `src/hooks/useOpenAnnotationLink.ts`, `useOpenWithBooks.ts`
- App-level upload entry: `appService.uploadBook(book)` at `src/services/appService.ts:269` (NOT `cloudService.uploadBook` — lower-level fn)
+
+## Resolved bugs (2026-06-18, dev branch) — cover + Android import
+
+Three distinct bugs, all verified (web CDP + real Xiaomi adb+CDP against PROD web.readest.com):
+
+1. **`/s` cover broken (broken `
`)** — root cause was **COEP not CORS**: `middleware.ts` sets `Cross-Origin-Embedder-Policy: require-corp` on every doc (for Turso SharedArrayBuffer); the cover `
` 302-redirects to an R2 presigned URL that can't carry a `Cross-Origin-Resource-Policy` header (S3/R2 presigns only override content-type/disposition/cache-control/etc., never CORP), so COEP blocks it. R2 already had CORS — irrelevant (CORS≠CORP; plain `
` is no-cors). Fix = `credentialless` scoped to `/s` (keeps `crossOriginIsolated` so EnvContext's `initReplicaSync`/Turso still boots there for synced users; require-corp elsewhere). NOT drop-COEP (EnvProvider wraps every route → replica needs SAB on /s too). Diagnosis gotcha: my first call was "CN network blocks R2" — WRONG; the deterministic cors-fetch-succeeds/no-cors-img-fails asymmetry + DevTools "needs Cross-Origin-Resource-Policy" Issues panel + `crossOriginIsolated:true` proved COEP. Middleware doesn't run under `output:export` so this is web-only (app never serves /s).
+2. **Android https share link extracted as a web clip** — `useClipUrlIngress` and `useOpenShareLink` both subscribe to `app-incoming-url`; clipper excluded annotation links but NOT share links → `https://web.readest.com/s/{token}` fell through to the article clipper (`invoke('clip_url')`→readability). `readest://share/` was spared only by the `http(s)` test. Fix = `if (parseShareDeepLink(url)) return;` in the clipper `handle`.
+3. **App import "Origin null is not allowed" / R2 CORS** — `ensureSharedBookLocal` new-import branch fetched `/share/{token}/download` → 302 → R2. **The Origin-null downgrade only happens on the SECOND cross-origin redirect hop**: app chain is `tauri.localhost → web.readest.com → R2` (hop2 nulls Origin → R2 CORS rejects); web chain is `web.readest.com → R2` (hop1, Origin PRESERVED → R2's CORS allows it). So **web needs NO change** — its renderer `fetch` follows the 302 fine (CDP-verified: 302-follow returns the full 2 036 204-byte epub, type:cors, on the /s page under COEP credentialless). Fix = app uses `tauriFetch` (native HTTP follows redirect + ignores CORS) on the SAME existing `/download` endpoint; web keeps `globalThis.fetch`. NO server change, NO new endpoint. **Dead-end first**: I added `?format=json` + `getShareDownloadUrl()` (webview fetch) — failed on device because (a) prod lacked `?format=json` so it 302'd and the webview fetch followed it to R2 → CORS, before tauriFetch ran, and (b) it was unnecessary since web follows the 302 anyway. Lessons: native must not depend on an un-deployed endpoint; and verify the first-vs-second-hop Origin behavior with CDP before adding machinery. Minor: deep link fires import 2-3× on cold-start/replay → one transient `/import` 500 seen (idempotency handled it). PR #4636.
diff --git a/apps/readest-app/src/__tests__/hooks/useSync.test.ts b/apps/readest-app/src/__tests__/hooks/useSync.test.ts
new file mode 100644
index 00000000..124d4bc8
--- /dev/null
+++ b/apps/readest-app/src/__tests__/hooks/useSync.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from 'vitest';
+import { countSyncedRecords } from '@/hooks/useSync';
+import type { BookDataRecord } from '@/types/book';
+
+const rec = (over: Partial): BookDataRecord => ({
+ id: 'id',
+ book_hash: 'hash',
+ user_id: 'user',
+ updated_at: 1,
+ deleted_at: null,
+ ...over,
+});
+
+describe('countSyncedRecords', () => {
+ test('counts only uploaded, non-deleted books', () => {
+ const records: BookDataRecord[] = [
+ rec({ book_hash: 'a', uploaded_at: '2024-01-01' }), // uploaded → counted
+ rec({ book_hash: 'b', uploaded_at: null }), // metadata-only → skipped
+ rec({ book_hash: 'c', uploaded_at: undefined }), // metadata-only → skipped
+ rec({ book_hash: 'd', uploaded_at: '2024-01-02', deleted_at: 123 }), // deleted → skipped
+ ];
+ expect(countSyncedRecords('books', records)).toBe(1);
+ });
+
+ test('does not require an upload state for non-book records', () => {
+ // configs/notes have no upload concept; count every live (non-deleted) record.
+ const records: BookDataRecord[] = [
+ rec({ book_hash: 'a' }),
+ rec({ book_hash: 'b', deleted_at: 5 }),
+ ];
+ expect(countSyncedRecords('configs', records)).toBe(1);
+ expect(countSyncedRecords('notes', records)).toBe(1);
+ });
+
+ test('returns 0 for empty, null, or undefined records', () => {
+ expect(countSyncedRecords('books', [])).toBe(0);
+ expect(countSyncedRecords('books', null)).toBe(0);
+ expect(countSyncedRecords('books', undefined)).toBe(0);
+ });
+});
diff --git a/apps/readest-app/src/hooks/useSync.ts b/apps/readest-app/src/hooks/useSync.ts
index 738b7442..19f40a0d 100644
--- a/apps/readest-app/src/hooks/useSync.ts
+++ b/apps/readest-app/src/hooks/useSync.ts
@@ -35,6 +35,19 @@ const computeMaxTimestamp = (records: BookDataRecord[]): number => {
return maxTime;
};
+// Count the records a pull surfaces to the user as "synced". Deleted records
+// never count. For books we additionally require an upload state: a book is
+// indexed in the cloud as soon as its metadata syncs, but updateLibrary only
+// adds books that are uploaded && !deleted, so counting metadata-only books
+// would over-report relative to what actually lands in the library.
+export const countSyncedRecords = (
+ type: SyncType,
+ records: BookDataRecord[] | null | undefined,
+): number => {
+ if (!records?.length) return 0;
+ return records.filter((rec) => !rec.deleted_at && (type !== 'books' || !!rec.uploaded_at)).length;
+};
+
const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
export function useSync(bookKey?: string) {
const router = useRouter();
@@ -145,7 +158,7 @@ export function useSync(bookKey?: string) {
}
break;
}
- return records?.filter((rec) => !rec.deleted_at).length || 0;
+ return countSyncedRecords(type, records);
} catch (err: unknown) {
console.error(err);
if (err instanceof Error) {
diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts
index 1d18f201..c3ba5689 100644
--- a/apps/readest-app/src/types/book.ts
+++ b/apps/readest-app/src/types/book.ts
@@ -464,6 +464,10 @@ export interface BookDataRecord {
user_id: string;
updated_at: number | null;
deleted_at: number | null;
+ // Only book records carry an upload state: a book is indexed in the cloud
+ // as soon as its metadata syncs, but is unavailable to peers until its file
+ // blob is uploaded. Absent on config/note records.
+ uploaded_at?: string | null;
}
export interface BooksGroup {