fix(library): count only uploaded, non-deleted books in synced toast (#4654)

* fix(library): count only uploaded, non-deleted books in synced toast

The "N book(s) synced" toast shown on pull-to-refresh and the
last-synced menu counted every non-deleted book record a pull
returned, including books indexed in the cloud as metadata only
whose file blob was never uploaded. Those books are never added to
the library (updateLibrary requires uploadedAt && !deletedAt), so
the toast over-reported.

Extract the count into a pure countSyncedRecords(type, records)
helper that excludes deleted records and, for books only, requires
uploaded_at — matching what actually lands in the library.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(memory): record agent notes for recent fixes

Persisted project-memory docs accumulated alongside recently merged
work (biometric app-lock, download-file scope regression, inline-block
column overflow, iOS instant-dict double popup, RSVP RTL words, web
security advisories) plus MEMORY.md/share-feature index updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-19 01:53:01 +08:00
committed by GitHub
parent be5862f08c
commit 451f0ccd90
11 changed files with 191 additions and 1 deletions
@@ -8,6 +8,10 @@
- [Platform Compat Fixes](platform-compat-fixes.md) - Android, iOS, Linux, macOS platform-specific bugs
- [Annotator & Reader Fixes](annotator-reader-fixes.md) - Highlight, selection, accessibility bugs
## Security
- [download_file scope Android regression](download-file-scope-android-regression.md) — #4639 strict `is_allowed` broke ALL Android downloads to app data dir (covers/dicts/books); `app.fs_scope()` lacks command-scoped capability globs; fix = `app.path()` base-dir membership. On-device CDP verify recipe + raw-invoke Channel trick
- [Security advisories 2026-06](security-advisories-web-2026-06.md) — all 4 GHSA fixed in PR #4638 (web: A OPDS-proxy SSRF + canonical `isBlockedHost` in network.ts + `isLanAddress` merge, B storage `isSafeObjectKeyName`, D Stripe `metadata.userId` ownership) + PR #4639 (native C: `transfer_file.rs` fs_scope guard). OPDS proxy can't require auth (`<img>` usage); strict `is_allowed` for C; shared-target worktree build-cache pollution gotcha
## Paginator Scroll Knowledge
- [Issue #4112 scroll-anchoring](issue-4112-scroll-anchoring.md) — RESOLVED (PR #4349). Scroll-anchoring suppressed at scrollTop 0 when prepending a section in scrolled mode; fix patterns (prepend compensation, eager backward preload, no-blank nav) + test & dev-server gotchas
- [Reading ruler line/column-aware](reading-ruler-line-aware.md) — ruler snaps to real lines; multi-column band spans one column; Range.getClientRects() returns tall block boxes that must be dropped; iframe frame-offset mapping; synthetic-key throttling
@@ -19,6 +23,7 @@
- [Paginated texture occlusion](paginated-texture-occlusion-4399.md) — #4399 host `.foliate-viewer::before` texture absent in paginated (shown in scrolled); opaque `#background` container (`= fallbackBg`) from the swipe-flash fix occludes it; shared `textureAwareBackground` helper + `hasTexture ? '' : fallbackBg` container
- [Dark-mode texture occluded by body bg (#4446)](dark-mode-texture-body-bg-4446.md) — RESOLVED (PR #4564): `body.theme-dark{bg !important}` from #4392 (v0.11.4, NOT foliate-js) painted iframe bodies opaque dark → occluded host texture + poisoned `docBackground` capture → opaque segments/view bgs; fix = `transparent !important` UNCONDITIONALLY (texture-gating would go stale: capture is once-per-section-load); CDP gotchas = patch ALL multiview iframes, stale preload views survive navigation ±2, load-listener sees exact capture-time state
- [Background overflows column (#4394, PR #4429)](paginator-gutter-bleed-asymmetry-4394.md) — paginated page bg stretched into the outer `--_outer-min` gutter → mixed cover/title 2-up spread shifted off-centre (~250px at 1920px). KEEP the grid (`--_outer-min` keeps margins symmetric); fix = clamp `computeBackgroundSegments` to `[containerStart,containerEnd]` (Math.max/Math.min) so bg stays in its column. 2 wrong tries first (bleed-gating, "page shouldn't be yellow"); foliate submodule needs dev-server RESTART to pick up edits
- [Inline-block column overflow](inline-block-column-overflow.md) — chapter skips to "Reference materials", clipping a large middle; EPUB wraps body in `display:inline-block` div → atomic-inline box can't fragment across columns → vertical overflow clipped (scrollHeight≫clientHeight). Fix = paginator `#demoteUnfragmentableBoxes` in `columnize` (col-mode, over-tall atomic-inline→fragmentable block). Renders via goTo/next but pages unreachable; scrolled mode unaffected
## Critical Files (Most Bug-Prone)
- `src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes)
@@ -59,10 +64,12 @@
- [Android sideload same versionCode](android-sideload-same-versioncode.md) — sideloaded APK reinstall allows EQUAL versionCode (only strictly-lower blocked); Play Store's increment rule does NOT apply to sideload. Nightly APKs share base versionCode and still install. Corrects a plausible-but-wrong review claim
## Feature Notes
- [Biometric app-lock (#4645)](biometric-app-lock-4645.md) — fingerprint/Face ID startup unlock layered over PIN (mobile); gate must read flag from `appLockStore` not un-seeded `settingsStore` (race); `tauri-plugin-biometric` is `#![cfg(mobile)]` (desktop clippy skips it; pin in root Cargo.lock); scope i18n manually (en unscanned, full extract churns drift)
- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — single-tap opens gallery/table-zoom in **reflowable** EPUBs (long-press unchanged); `iframe-long-press` message renamed to `iframe-open-media`, hook `useLongPressEvent``useOpenMediaEvent`; shared `detectMediaTarget`; `handleClick` got `isFixedLayout`
- [#4584 tap-death investigation](issue-4584-tap-death-investigation.md) — UNFIXED; `isPopuped` self-heals (RED HERRING, don't "fix" it); likely WebView-148-specific (emulator=133 can't repro); Android emulator/CDP gesture-verification gotchas (swiftshader ANR=artifact, CDP can't native-select, screenX=0)
- [Dictionary lemmatization (#4574)](dict-lemmatization-4574.md) — inflected selections (`ran`/`mice`/`analyses`) resolve to base headwords (`run`/`mouse`/`analysis`) in dicts that store only lemmas (ODE). Pluggable `lemmatize/` registry (default English, explicit non-English no-op), English rules+irregulars, appended to tail of `buildLookupCandidates` so exact match wins; over-generate + dict-validates; `-ses→-sis` ordered before `-es`
- [Word Lens inline gloss (feat/word-wise)](wordlens-feature.md) — Kindle-style native-language hint above hard words; CFI-safe via `<ruby cfi-skip>…<rt cfi-inert>` (epubcfi hoist+merge, NOT just tree-walk); TTS/search isolation (tags:['rt'] + rangeTextExcludingInert + search attributes:['cfi-inert']); gloss data = curated starters, full asset built by `build-wordlens-data.mjs` (ECDICT/CC-CEDICT+HSK)
- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) — iOS emits multiple `selectionchange`/long-press → instant sys-dict fired 2-3×; deferredAction `fired` once-per-gesture latch + `beginGesture`; tap-to-deselect re-fire fixed by `isLongPressHold` 300ms gate (!isAndroid); Word Lens `wantWordLensDict` now routes via `handleDictionary` to honor system dict
- [Edge TTS word highlighting (#4017, PR #4566)](edge-tts-word-highlighting-4017.md) — keep sentence marks, add word highlight via `audio.metadata` WordBoundary (verbatim input span, 100-ns ticks) synced to `audio.currentTime` by rAF; readaloud endpoint gates on UA (Edg, non-headless) NOT Origin; fixed browser `new WebSocket(url,{headers})` SyntaxError (wss never worked on web); overlay = `<path>` in FOLIATE-PAGINATOR shadow root; dev-web verify recipe (browse --proxy + UA spoof, never Origin header)
- [Reference Pages (#672+#4542, PR #4549)](reference-pages-672-4542.md) — 'reference' progressStyle from foliate `pageItem`/`book.pageList` (numeric-max total rule, roman-tail safe); per-book `referencePageCount` via skipGlobal save; verification EPUBs + dev-web synthetic drag-drop import trick; locale-tail rebase-conflict recipe (checkout --ours → re-extract → re-translate)
- [OPDS Firefox strict-XML parse (#4479)](opds-firefox-strict-xml-4479.md) — MEK feed has junk after `</feed>`; Firefox DOMParser → `<parsererror>` (silent back-nav), Chrome lenient; `parseOPDSXML` slices root start→last close tag; jsdom mirrors Firefox; wired into page.tsx + validateOPDSURL + feedChecker (latter also #4181 `looksLikeXMLContent` swap)
@@ -88,6 +95,7 @@
- [Table dark-mode tint regression (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` color-mix tint in `getColorStyles` must stay gated on `overrideColor` (gate added #2377, removed #4055, re-broke → #4419); safe now that #4392 light-bg rewriters handle #4028 zebra legibility; SAME rule paints vertical-TOC `.space`/▉ spacer cells (▉ U+2589 = blank glyph, contours=0) → "spacing changes" symptom; both fixed by the gate
- [Double-click-drag turns page (#4524)](dblclick-drag-pageturn-4524.md) — web double-click+drag selection also turned the page; 1st click's deferred single-click (250ms) fires mid-drag while 2nd-click button held; fix = `isMouseDown` flag in `iframeEventHandlers.ts` gates the deferred `postSingleClick`; synthetic-repro gotchas (shadow-DOM iframe walk, chained-repro timing pollution, reload to re-bind listeners)
- [RSVP font face/family (#4519)](rsvp-font-settings-4519.md) — RSVP word was hardcoded `font-mono`; now mirrors the reader font via `getBaseFontFamily(viewSettings)` (new export in `style.ts`, shares `buildFontFamilyLists` with `getFontStyles`). Overlay renders in the TOP document (portal to body) where custom + basic Google fonts are mounted; known gap = built-in CJK web fonts only in top doc when `isCJKEnv()`
- [RSVP RTL word display (#4630)](rsvp-rtl-word-display-4630.md) — Arabic/RTL word window showed separated, reversed letters: ORP focus-letter split slices words by char index (breaks shaping/order); fix = `isRTLText` → render RTL whole via the CJK `.rsvp-word-whole` branch with `dir=rtl`. Literal-RTL-char Edit pitfall → write regex with `\u` escapes
- [Footnote aside border line (#4438)](footnote-aside-namespace-order-4438.md) — v0.11.4 regression: stray horizontal line below footnote marker. #4383 inlined custom `@font-face` BEFORE the `@namespace epub` (which lived in `getPageLayoutStyles`), invalidating it per CSS spec → namespaced `aside[epub|type~="footnote"]{display:none}` dropped → book's `aside{border:3px double}` showed. Only with custom fonts loaded. Fix = hoist `@namespace` to front of `getStyles`. Repro needs XHTML (`epub:type` namespaced only in XML); Playwright `setContent` parses HTML and won't reproduce
- [Scrolled-mode notch mask vs texture (#4486)](notch-mask-texture-4486.md) — top inset mask occluded the bg texture; full-cell + clip-path paint-box-matching for tile alignment; CDP-inject + MAE seam verification on device; adb taps in status-bar region eaten by SystemUI
- [Paragraph-mode accidental exit + off-center bar (#4474)](paragraph-mode-accidental-exit-4474.md) — backdrop/center taps exited focus mode (stray "too high/low" taps); `ParagraphBar` only reshows on mousemove (no touch reshow) so can't just delete tap-exits → new `paragraph-show-controls` event reveals the bar instead. Also bar `absolute``fixed`: it centered on the gridcell which a pinned sidebar shifts right, while the paragraph centers on the `fixed inset-0` overlay/viewport
@@ -0,0 +1,17 @@
---
name: biometric-app-lock-4645
description: "Biometric (fingerprint/Face ID) startup unlock layered over the PIN app-lock; gotchas for applock-store seeding, mobile-cfg crate, and scoped i18n"
metadata:
node_type: memory
type: project
originSessionId: 7d0f633b-0e69-405e-a4b4-5a1b19723d86
---
Biometric app-lock (#4645, PR #4650, branch `feat/biometric-app-lock`): biometrics unlock at startup on Android/iOS, app PIN as fallback; desktop/web unchanged. Layered over the existing PIN lock — `pinCodeEnabled` stays the master switch, PIN crypto in `libs/crypto/applock.ts` untouched. All plugin access isolated behind `src/services/biometric.ts` (guarded no-op off mobile; `authenticate` uses `allowDeviceCredential:false` so PIN is the only fallback). New setting `biometricUnlockEnabled` defaults true only for NEW mobile setups; existing PIN users (undefined→off) opt in via a mobile-only toggle.
Non-obvious gotchas (cost real review/rework here):
- **`AppLockScreen` must read startup-snapshot settings from `appLockStore`, NOT `settingsStore`.** `Providers` seeds ONLY the app-lock store via `useAppLockStore.initialize()` (from its own `loadSettings()`), before the gate mounts. `settingsStore.settings` starts `{}` and is seeded later by page-level init — reading the flag from `settingsStore` in the gate RACES and silently no-ops. Fix = thread the value through `initialize()` like `pinHash`/`pinSalt`.
- **`tauri-plugin-biometric` is `#![cfg(mobile)]`** — empty on desktop, so registration must be `#[cfg(any(target_os="ios",target_os="android"))]`-gated (like `haptics`/`sign-in-with-apple`). Desktop `clippy:check` does NOT compile that line, so the Rust side needs a real device build to verify. The dep pin lands in the **workspace-root `Cargo.lock`** (resolved when cargo runs), NOT `src-tauri/Cargo.lock` (which doesn't exist/track here).
- **Scoped i18n without churn:** `public/locales/en` is NOT scanner-managed (key-as-content fallback). Running the full `i18n:extract` reconciles ALL strings and pulls in unrelated drift already on main (e.g. Word Lens keys). For a clean PR, discard the scanner output and add only your new keys to the 33 langs in `i18n-langs.json`. "Face ID"/"Touch ID" are Apple brands — keep verbatim in every locale.
Related: [[ios-instant-dict-double-popup]] (same applock/gate area), [[custom-fonts-reincarnation-4410]] (settings-sync flag patterns).
@@ -0,0 +1,22 @@
---
name: download-file-scope-android-regression
description: "#4639 strict is_allowed broke ALL Android downloads to app data dir (covers/dicts/books); fix = app.path() base-dir membership, not glob scope"
metadata:
node_type: memory
type: project
originSessionId: e78227be-6260-405a-88fb-48ffe4b20615
---
Regression from [[security-advisories-web-2026-06]] PR #4639 (commit 4025c4d7b). On Android every `download_file` into the app's own data dir failed: `permission denied: path not in filesystem scope: /data/user/0/com.bilingify.readest/Readest/{Books/<hash>/cover.png, Dictionaries/<id>/*.mdx, ...}`. The error string IS `transfer_file.rs Error::Forbidden` from `ensure_path_allowed`.
**Root cause (non-obvious):** `app.fs_scope().is_allowed(p)` returns **false** for the app's own files on Android. `FsExt::fs_scope()` returns the GLOBAL `state::<Scope>().scope`, but the capability scope patterns that cover the data dir (`$APPDATA/Readest/**/*`, `**/Readest/**/*`) are **command-scoped**, NOT in that global scope. The fs plugin's `resolve_path` (tauri-plugin-fs `commands.rs`) passes because it checks `fs_scope.scope.is_allowed(p) || scope.is_allowed(p)` where the 2nd `scope` is rebuilt from `global_scope.allows()+command_scope.allows()` per-command — that's where those patterns live. This is the SAME gap `dir_scanner::read_dir` works around with `|| contains("Readest")`. So #4639's note "Chose STRICT is_allowed (NOT read_dir's contains-Readest hatch)" was the bug — strict `is_allowed` rejects the app's own dir on Android.
Why fs-plugin writes work but `download_file` didn't: app writes via `baseDir: AppData` + relative path → `webview.path().resolve(rel, AppData)` → canonical form matched by the per-command scope. `download_file`/`upload_file` use raw `tokio::fs` with a JS-supplied ABSOLUTE path, so none of that applies.
**WRONG first attempt (don't repeat):** canonicalizing the symlink (`/data/user/0/<pkg>``/data/data/<pkg>`, since `is_allowed` only canonicalizes EXISTING paths and a download target doesn't exist yet) then re-calling `is_allowed`. Verified on-device it STILL fails — the patterns aren't in the global scope at ALL, so no path form matches. The canonicalize-existing-ancestor helper is still useful, just for the prefix check below, not for `is_allowed`.
**Shipped interim fix** (PR #4651, commit 75b469931): owner rejected the base-dir-membership version as over-engineered ("bloody hard coded" dir list) and asked to mirror `dir_scanner` instead. `ensure_path_allowed` = reject relative+`..` (`has_disallowed_components`) → `if app.fs_scope().is_allowed(p) || is_within_app_storage(p) Ok`. `is_within_app_storage(file_path, app_identifier) = file_path.contains("Readest") || file_path.contains(app_identifier)` where `app_identifier = &app.config().identifier` (NOT a hardcoded literal; `config()` is inherent on AppHandle — NO `use tauri::Manager`). The bundle id (`com.bilingify.readest`) is in EVERY Android sandbox path incl. the cache dir, so it closes the OPDS-to-`$APPCACHE` gap that `contains("Readest")` alone misses (`Readest` is the `DATA_SUBDIR`, capital-R; cache dir has only the lowercase bundle id). `..` rejection keeps GHSA-55vr-pvq5-6fmg (`~/.ssh/id_rsa` has neither marker). Substring posture = same as `dir_scanner`. **Follow-up (deferred):** replace the substring fallback with `BaseDirectory`+relative resolved via `app.path()` (the app already has `appService.resolvePath()``{baseDir, fp}`; callers currently flatten it to an absolute string) so targets are in-scope by construction.
(Rejected earlier attempt, kept for context: base-dir-membership via `app.path()` dirs + `is_inside_any` canonicalizing both sides for the `/data/user/0``/data/data` symlink. Correct but owner found the dir enumeration ugly.)
**On-device verify recipe (Xiaomi fuxi 2211133C, real release-signed devtools build):** `pnpm dev-android` = `tauri android build -t aarch64 -- --features devtools && adb install -r .../app-universal-release.apk`. Local keystore (`gen/android/keystore.properties``/Users/chrox/dev/Android/keys/upload-readest-keystore.jks`, alias `upload`) matches installed signer → `-r` PRESERVES user data (dicts/books). CDP probe: `adb forward tcp:9333 localabstract:webview_devtools_remote_<pid>` (socket name = app pid; stale sockets linger — pick the one matching `adb shell pidof`); fetch `/json/list` via node http (NOT curl — mishandles WebView framing); Node v24 has global `WebSocket`. Raw `invoke('download_file',...)` needs a Channel for `on_progress`: pass `{ ['__TAURI_TO_IPC_KEY__']: () => '__CHANNEL__:'+I.transformCallback(()=>{}) }`. Result: in-scope `/data/user/0/.../Readest/x.bin` → OK; `/data/local/tmp/evil.bin` → still Forbidden. `appData=/data/user/0/com.bilingify.readest` (no `/files`); appCache/temp=`.../cache`.
@@ -0,0 +1,18 @@
---
name: inline-block-column-overflow
description: Foliate paginator fix — atomic-inline (inline-block) boxes too tall to fragment clip content in paginated mode
metadata:
node_type: memory
type: project
originSessionId: f0c35f7b-d4ff-4275-9f13-7019b0e167d9
---
Bug: a chapter's content jumps straight to its "Reference materials", silently skipping a large middle (deep-dive/wrap-up). Repro book: "System Design Interview Vol.2" (System Design EPUB), Chapter 8 `OEBPS/c554.xhtml`, reported "at page 346".
Root cause: the EPUB's own CSS wraps the whole chapter body in a `<div>` 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]].
@@ -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.
@@ -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 `<span>`, 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.
@@ -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/<kind>/<id>/<file>` 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/<pkg>-<hash>` 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 `<img src={getProxiedURL(...)}>` (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 <file>` 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.
@@ -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 `<img>`)** — root cause was **COEP not CORS**: `middleware.ts` sets `Cross-Origin-Embedder-Policy: require-corp` on every doc (for Turso SharedArrayBuffer); the cover `<img>` 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 `<img>` 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.
@@ -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>): 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);
});
});
+14 -1
View File
@@ -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) {
+4
View File
@@ -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 {