Commit Graph

19 Commits

Author SHA1 Message Date
Amir Pourmand b8d986cbd6 fix(docker): fix Docker image latest tag and production runtime errors; add dev compose file, Codespace support, and semver release tagging (#7) (#4277)
* fix: also tag Docker image as latest on main branch push

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/bbc0f66e-7488-4d9d-976b-434588b3faa8



* fix: production Dockerfile missing patches/.env.web; add compose.dev.yaml for dev hot-reload

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/e27bba4e-b8ea-4694-b44d-6308aa778d41



* fix: add devcontainer to init submodules; add clear Dockerfile error when submodules missing

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/16d24ae0-d2e8-4523-b47e-4969dd587c50



* simplify production-stage: COPY --from=build /app /app instead of selective copies

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/356fabe4-f944-48b6-a409-41640480d52f



* fix: add CI=true to dev compose to prevent pnpm no-TTY abort

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/ddc30fef-2c76-452d-aad6-d688ca912a1a



* fix: add semver Docker image version tags on release

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/18cbc3c6-ba92-4e63-bef9-93dcf6698c21



---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-05-23 10:39:15 +08:00
Amir Pourmand 9ad43aa8b2 feat(docker): add GHCR and Docker Hub image publishing (#4250)
* Add GHCR and Docker Hub image publishing with fully runtime-configurable pull-first Docker setup (#1)

* feat: add container image publishing workflow and pull-based compose setup

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304

* refine docker publishing workflow and pull-first compose docs

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304

* chore: temporarily expose docker publish run results for pr branch

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/c946a2f2-2219-4dea-a829-61b287bc4859

* fix: update pinned SHAs for setup-qemu-action and setup-buildx-action to v3 heads

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/0db6957e-476b-48e0-acf3-bee6964c3b32

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore: switch all workflow action refs from SHA pins to stable version tags

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/19334b37-9b4c-45df-9c3c-81a497cef8e8

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: restore missing `with:` blocks lost during SHA-to-tag substitution

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/f204f742-5b7d-4f05-9647-03b9db86ea3d

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix(ci): checkout submodules for docker image workflow

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/72309e8a-6c7c-4004-902a-565f67e5c15f

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* feat(docker): support runtime client env for pulled web image

* refactor(web): serve runtime config via script endpoint

* fix(web): escape runtime config script payload

* fix: align docker runtime config with internal/public backend urls

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore: align published workflow tags with docs

* docs: clarify runtime config precedence and linux host mapping

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93

* refactor: move storage/quota config from build args to runtime env

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/da9b749e-0b1c-47b9-b474-5009765b6ea6

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: load runtime config in pages router app shell

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/b375132c-8317-4c98-b437-ae48c0153e3d

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: apply biome formatting for runtime config quota helpers

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/778f5d75-884e-401b-b7cb-4ab40bc64a11

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>
Co-authored-by: Amir Pourmand <pourmand1376@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: S3_PUBLIC_ENDPOINT, _document.tsx for beforeInteractive, runtimeConfig fallbacks, README port (#2)

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4f92f818-c008-4caa-9684-d530500b5fb2

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: reformat runtimeConfig.ts to satisfy biome formatter (#3)

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4eea77f4-67ab-4428-b59e-0b21be988037

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-21 19:57:35 +02:00
loveheaven ae81cd0151 feat(annotator): support global highlights that fan out across all matching positions (#4257)
Introduces a 'global' annotation flag so a highlight/note created on one occurrence of a phrase is automatically applied to every matching occurrence in the book (and stays applied across reloads). Renders these expansions as transient overlays without creating duplicate persisted notes. This flag will not show when the book is fixed layout like PDF or CBZ.

- types: add 'global?: boolean' to BookNote and DBBookNote; transform layer round-trips the field, with regression coverage ensuring older clients do not clobber it on write-back.

- db: new migration 013_add_book_notes_global.sql adds nullable 'global' column to public.book_notes; init schema.sql updated to match.

- annotator: new utils/globalAnnotations.ts handles cfi expansion / text-match search across the spine and overlay synthesis. Annotator.tsx fans out global notes on load and on overlay creation; AnnotationPopup and HighlightOptions expose a toggle to mark a highlight as global.

- sync path is transparent: a global note created on another device is fanned out locally on next render with no extra UI required.
2026-05-21 18:59:07 +02:00
Huang Xin 0b18de0581 feat(send): Send to Readest — multi-channel capture into your library (#4230)
* feat(send): Send to Readest — multi-channel capture into your library

A Send-to-Kindle equivalent: email, web-upload, share, or one-click
capture books and articles into the cloud library; they sync to every
device.

Architecture (client-side processing): out-of-app channels drop a raw
payload into a per-user send_inbox; Readest clients drain it through one
shared ingestService.ingestFile(). The server never parses or converts.

- ingestService.ingestFile() — channel-agnostic import orchestration
  extracted from library/page.tsx (DI-based, forceUpload support).
- send_addresses / send_allowed_senders / send_inbox tables + RLS + 4
  SECURITY DEFINER claim/lease RPCs (migration 012_send_to_readest.sql).
- Conversion subsystem (DOCX/RTF/HTML/article/TXT -> EPUB) in a Web Worker.
- send-email Cloudflare Email Worker; inbox-drainer controller +
  useInboxDrainer hook; /api/send/* routes.
- Send to Readest settings panel: inbound address, approved-sender
  allowlist, recent activity, per-device drain toggle.
- /send web page (file drop + article URL) + SSRF-guarded fetch-url proxy.
- OS-shared files routed through ingestFile; Manifest V3 browser extension.

Security: inbox state changes only via SECURITY DEFINER RPCs (clients get
SELECT-only on send_inbox); approved-sender allowlist gates email;
SSRF guard on the one server-side URL fetch; inbox payload signed URLs
authorize against send_inbox.user_id.

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

* chore: run format:check in the pre-push hook

Biome format checking is fast (~0.4s), so gate pushes on it too — catches
mis-formatted files that bypassed the staged-only pre-commit hook before
they reach CI.

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

* fix(send): address CodeQL security findings

- ReDoS (senders.ts): the email regex had ambiguous quantifiers around
  the literal dot. Rewrote it linear-time (domain labels exclude '.')
  and cap the input at 254 chars.
- XSS (convertToEpub.ts): run untrusted HTML through DOMPurify
  (sanitizeForParsing — keeps document structure) before DOMParser, so
  title extraction and Readability never parse executable markup.
- SSRF (fetch-url.ts): harden the host guard — block bare single-label
  hostnames, IPv4-mapped IPv6, CGNAT/benchmark/multicast ranges, and the
  unspecified address. DNS rebinding stays a documented residual risk.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:06:52 +02:00
Huang Xin 6e7c9d1395 feat(sync): bundled settings replica kind for cross-device prefs and credentials (#4094)
* feat(sync): add bundled `settings` replica kind for cross-device prefs and credentials

Adds a single-row `settings` replica that syncs a whitelist of
`SystemSettings` fields across devices via per-field LWW (one entry
per dot-namespaced path). Plaintext for theme / highlight colour /
TTS configuration; encrypted (AES-GCM under the user's sync
passphrase) for kosync / Readwise / Hardcover credentials.

Highlights:
- Push-side diff against an in-memory snapshot for plaintext paths
  and a localStorage SHA-256 hash for encrypted paths, so a refresh
  doesn't re-publish or re-prompt for the passphrase.
- Pull-side cipher-fingerprint dedupe + per-row passphrase gate;
  decryption failures surface as toasts (wrong passphrase / orphan
  cipher) instead of silent drops.
- Auto-recovery for orphaned ciphers: when a row references a
  saltId no longer in `replica_keys`, clear the local hash and
  re-encrypt under the current salt on the next save.
- Single in-flight `/sync/replica-keys` fetch with a value cache
  to coalesce the boot-time burst of concurrent unlock callers.

* fix(sync): guard settings dot-path helpers against prototype-polluting keys

Reject `__proto__`, `constructor`, and `prototype` segments in the
settings adapter's `readPath` / `writePath`. Every caller currently
passes a constant from `SETTINGS_WHITELIST`, so the guard is purely
defensive — but it silences the CodeQL prototype-pollution warning
on PR #4094 and keeps the helpers safe if a future call site ever
forwards an untrusted path.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:03:23 +02:00
Huang Xin 712d564e9d feat(sync): encrypted OPDS credentials + Tauri keychain (PR 4c + 4d) (#4090)
* feat(sync): encrypt opds_catalog credentials end-to-end (TS path)

Wires encrypted-credential sync for opds_catalog via the CryptoSession
shipped in PR 4a (#4084) plus a new publish/pull crypto middleware.
TS-only — native still uses ephemeral storage (re-enter passphrase per
launch); PR 4d wires the OS keychain.

- ReplicaAdapter gains optional `encryptedFields: readonly string[]`.
  Adapters stay sync; the middleware handles the crypto round trip.
- replicaCryptoMiddleware.ts: encryptPackedFields drops the named
  fields from the push when the session is locked (no plaintext
  leak); decryptRowFields drops them on pull failure (local
  plaintext preserved by the store merge).
- replicaPublish / replicaPullAndApply invoke the middleware.
- OPDS adapter declares encryptedFields = [username, password] and
  now pack/unpack them as plaintext.
- passphraseGate.ts: ensurePassphraseUnlocked coalesces concurrent
  calls, prompts via the registered prompter with kind=setup|unlock,
  throws NO_PASSPHRASE on cancel.
- PassphrasePromptModal mounted at the Providers root; registers
  itself as the gate prompter.
- CryptoSession.forget() wipes server-side envelopes + salts.
- Migration 010 + replica_keys_forget RPC; DELETE
  /api/sync/replica-keys + client wrapper.
- SyncPassphraseSection on the user page: status / Set / Unlock /
  Lock / Forgot.
- CatalogManager pre-save: ensurePassphraseUnlocked when credentials
  are present; user cancel saves locally without sync.

Plan updated: PR 4 split documented as 4a/4b/4c/4d.

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

* feat(sync): persist sync passphrase via OS keychain (Tauri)

Replaces the EphemeralPassphraseStore stub on native with real
OS-keychain storage so users don't re-enter their sync passphrase
every launch. Web stays on the in-memory ephemeral store by design.

Native bridge plugin gains 4 commands wired across all platforms:

- Rust desktop (`keyring` crate): macOS Keychain on apple-native,
  Windows Credential Manager on windows-native, Linux libsecret/
  Secret Service on sync-secret-service. Per-target features so each
  platform compiles only the backend it needs.
- iOS Swift: Security framework Keychain (kSecClassGenericPassword,
  SecItemAdd / Copy / Delete).
- Android Kotlin: androidx.security EncryptedSharedPreferences
  (AndroidKeystore-derived AES-GCM master key,
  AES256_SIV / AES256_GCM key/value encryption).

TS layer:

- TauriPassphraseStore wraps the bridge calls. set is fail-loud
  (surfaces keychain rejection); get is fail-soft (returns null on
  any error so the gate prompts).
- createPassphraseStore returns ephemeral synchronously;
  upgradeToKeychainIfAvailable swaps the singleton to
  TauriPassphraseStore on Tauri after probing the bridge. CryptoSession
  resolves the store via createPassphraseStore() each touch so the
  swap is transparent.
- CryptoSession.tryRestoreFromStore: silent unlock at boot. Stale-
  entry recovery clears the store when the account has no salt
  server-side. unlock/setup persist; forget also clears the store.
- Providers boot effect: upgrade keychain → silent restore.

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

* fix(sync): make encrypted-credential pull actually decrypt + UX polish

PR 4c shipped the encrypt path but the pull side silently dropped
ciphers when locked, the modal was busy with double-rings, and web
re-prompted on every page refresh. This rolls up the post-test
fixes + UX polish:

Pull-side decrypt:
- decryptRowFields takes an `onLocked` callback the orchestrator
  wires to the passphrase gate; encountering a cipher field with a
  locked session now triggers the lazy-prompt path instead of
  dropping the field.
- replicaPullAndApply re-applies the unpacked row for metadata-only
  kinds even when a local copy exists, so the now-decrypted creds
  reach the store (the binary-kind skip-if-local optimization
  doesn't apply).
- Cipher fingerprint comparison: capture the row's `cipher.c` for
  each encrypted field, compare against the local record's
  lastSeenCipher. Same → skip prompt + decrypt entirely. Different
  (rotation / value change on another device) → prompt to
  re-decrypt. Fingerprint persists via OPDSCatalog.lastSeenCipher.

Web persistence:
- SessionStoragePassphraseStore: passphrase survives page refresh
  within the same tab, dies on tab close. Replaces
  EphemeralPassphraseStore as the default on web. Avoids
  localStorage / IndexedDB to keep the tab-scoped trust boundary.

UI:
- Renamed PassphrasePromptModal → PassphrasePrompt; modernized: filled
  input style with single subtle focus border, btn-primary +
  btn-ghost replaced with leaner custom buttons. eink-bordered +
  btn-primary classes give the dialog correct e-paper rendering.
- globals.css: suppress redundant outline/box-shadow on focused text
  inputs / textareas (the element's own border is the focus
  indicator).
- AGENTS.md: documents the e-ink convention (`eink-bordered`,
  `btn-primary` for inverted CTAs, etc.) so future widgets ship with
  e-paper support.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:22:49 +02:00
Huang Xin 6bfeb295d2 feat(sync): add opds_catalog replica kind (plaintext fields) (#4087)
Wires OPDS catalogs through replica sync as a metadata-only kind.
Plaintext fields only in this PR — encrypted credentials (username,
password) ship in the follow-up alongside the SyncPassphrasePanel UI
and Tauri keychain backend.

- Migration 009 extends the kind allowlist with 'opds_catalog'.
- replicaSchemas adds opdsCatalogFieldsSchema (name, url, description,
  icon, customHeaders, autoDownload, disabled, addedAt) with a 50-row
  per-user cap.
- New opdsCatalogAdapter is metadata-only (no `binary` capability).
  Stable cross-device id from md5("opds:" + url.lower()) so two
  devices that import the same URL converge to one row instead of
  duplicating.
- New customOPDSStore (zustand) hydrates from SystemSettings,
  publishes upserts/deletes through the replica pipeline, preserves
  local-only username/password when overlaying remote updates, and
  strips tombstones at the persistence boundary so existing
  useSettingsStore readers (useOPDSSubscriptions, pseStream,
  app/opds/page.tsx) need no migration.
- replicaPullAndApply branches on adapter.binary so metadata-only
  kinds skip the bundleDir requirement and the manifest/binary path.
- CatalogManager rewires Add / Edit / Remove / Toggle / Add-popular
  through the new store.

Plan update bundled in: tenet 8 (scalar settings sync via a bundled
row; collections sync per-record), per-kind allowlist now includes a
`settings` singleton that will collapse PRs 5 + 6+ into one bundled
adapter, and PR 4 is split into 4a (already merged) / 4b (this) / 4c
(encrypted credentials + UX).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 05:33:18 +02:00
Huang Xin 35227ecd61 feat(sync): wire crypto session for encrypted-field sync (#4084)
Adds the per-account PBKDF2 salt endpoint and an in-memory CryptoSession
that derives keys lazily per saltId. Lets future kinds (OPDS catalogs in
PR 4b) encrypt/decrypt fields without re-deriving on every operation.

- Migration 008: replica_keys_{create,list} RPCs round-trip the bytea
  salt as base64; SECURITY INVOKER, RLS-gated by the existing replica_keys
  policies.
- /api/sync/replica-keys GET/POST endpoint matches the dual app/pages
  shape used by /api/sync/replicas.
- ReplicaSyncClient.{listReplicaKeys,createReplicaKey} wraps the endpoint.
- CryptoSession.{unlock,setup,encryptField,decryptField,lock} caches
  derived keys per saltId; foreign envelopes trigger a lazy re-list +
  derive. Iterations injectable so tests run with PBKDF2 ITER=1000.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:05:04 +02:00
Huang Xin 981579c255 feat(sync): cross-device custom font sync (#4077)
* refactor(sync): kind-agnostic replica primitives

Extract dict-only sync into shared primitives (registry, pull/apply
orchestrator, persist env, schema allowlist) so other kinds can plug
in. Companion changes: per-replica Storage Manager grouping,
useReplicaPull boot-race recovery, manifest=null reconciliation on
every boot pull, copyFile takes explicit srcBase + dstBase, settled-
event helpers, lenient webDownload Content-Length (R2/S3 signed URLs
commonly omit it), and generic "File" transfer toast copy any replica
kind can share.

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

* feat(sync): cross-device custom font sync

Plug the font replica adapter into the kind-agnostic primitives:
font store gains replica wiring, custom font import publishes the
replica row + queues a binary upload, and bootstrap registers the
font adapter and download-complete handler.

Includes legacy flat-path migration so pre-existing fonts sync
without re-import, full @font-face activation on auto-download
(load + mount the rule, mirroring manual import), and a fix to
createCustomFont so contentId / bundleDir / byteSize survive the
trip through addFont — otherwise import-time publish silently
no-oped on missing contentId.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:28:44 +02:00
Huang Xin cbdc3b8f52 feat(sync): wire dictionary store through replica sync (follow-up to #4075) (#4076)
* feat(sync): cross-device dictionary sync

Custom MDict / StarDict / DICT / SLOB dictionaries now sync across
signed-in devices via the replica layer.

- Store mutations publish replica rows with field-level LWW + tombstones.
- Re-importing the same content (renamed or after delete) preserves the
  user's label and reincarnates the server row instead of duplicating.
- Manifest commits after binary upload so other devices never see a row
  whose binaries aren't on cloud storage yet.
- Pull-side orchestrator creates a placeholder dict, queues the binaries
  via TransferManager, and clears the unavailable flag on completion.
- Toast copy branches by transfer kind so dict uploads don't read
  "Book uploaded".

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

* fix(sync): boot pull and binary download path

- Defer the boot pull until TransferManager is initialized so download
  enqueues aren't dropped.
- Auto-persist the local dict store after applyRemoteDictionary; otherwise
  the next loadCustomDictionaries wipes the in-memory rows.
- Boot pull passes since=null so a device whose cursor advanced past
  unpersisted rows can still recover.
- Skip pulling when not authenticated instead of logging
  "SyncError: Not authenticated" on every boot of a signed-out device.
- downloadReplicaFile resolves the destination against the kind's base
  dir; binaries previously landed at the literal lfp and openFile then
  failed with "File not found".

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

* refactor(sync): per-page useReplicaPull hook

Lifts the boot-time pull out of EnvContext into a hook each page mounts
for the kinds it needs: useReplicaPull({ kinds: ['dictionary'] }).
Library page and the shared Reader component opt in. The hook fires 10s
after page load (so feature mounts hydrate first), dedups per-kind
across navigation, and releases the slot on failure so a later mount
can retry. Future kinds plug into the hook's per-kind switch.

Also closes two refresh-loop bugs:

- Hydrate the dict store from settings BEFORE the apply loop, so the
  auto-persist doesn't clobber persisted rows that the in-memory store
  hadn't yet read. Library-page refresh was the visible victim.
- Skip the download queue when every manifest file is already on disk
  under the resolved bundle dir. Refreshing is a no-op; partial-
  download recovery still queues because some files would be missing.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:39:38 +02:00
Huang Xin 3b348c8f35 feat(sync): CRDT replica sync foundation (#4075)
* feat(sync): foundation for CRDT-based cross-device replica sync (Phase 1+2)

Adds the primitives and orchestration layer for syncing user-imported
assets (dictionaries, fonts, textures, OPDS catalogs, dict settings)
across devices via a polymorphic `replicas` table with field-level LWW
under HLC ordering. Phase 1 ships the foundation (CRDT, crypto, server
schemas, SQL migrations, push/pull endpoint); Phase 2 adds the adapter
registry, HTTP client, and sync manager. No modifications to existing
book sync — additive only.

Phase 1:
- src/libs/crdt.ts — HlcGenerator (monotonic + remote-absorption +
  clock-regression-safe), per-field LWW with deviceId tiebreak,
  remove-wins tombstones, reincarnation token revival.
- src/libs/crypto/{derive,encrypt,envelope,passphrase}.ts —
  PBKDF2-600k key derivation (OWASP 2024), AES-GCM round-trip,
  envelope {c,i,s,alg,h} with SHA-256 sidecar integrity check,
  passphrase storage abstraction (web ephemeral; Tauri keychain stub).
- src/libs/replica-schemas.ts — Zod-backed allowlist (dictionary only
  in PR 1), 64KiB row cap, 64-field cap, schemaVersion bounds,
  filename validator.
- src/libs/replica-sync-server.ts — push batch validation
  (auth + allowlist + schema + HLC ±60s skew clamp).
- src/pages/api/sync/replicas.ts — POST/GET endpoint wrapping the
  Postgres crdt_merge_replica function via RPC.
- docker/volumes/db/migrations/003_add_replicas.sql — replicas table
  + replica_keys table + RLS.
- docker/volumes/db/migrations/004_crdt_merge_replica_fn.sql — atomic
  per-field LWW merge function (forwards-compat preserves unknown
  fields).

Phase 2:
- src/services/sync/replicaRegistry.ts — adapter contract
  (core + optional BinaryCapability + LifecycleHooks per eng review).
- src/libs/replica-sync-client.ts — HTTP wrapper mapping status codes
  to typed SyncError codes.
- src/services/sync/replicaSyncManager.ts — 5s debounced push,
  immediate flush on visibilitychange/online, per-kind pull cursor,
  remote HLC absorption.

Tests: 125 new (crdt 26, crypto 32, schemas 21, server 16, client 12,
registry 6, manager 12). Full suite 3656 passing, lint clean. Existing
book/config/note sync paths untouched.

Plan: ~/.claude/plans/vivid-orbiting-thimble.md
CEO plan: ~/.gstack/projects/readest-readest/ceo-plans/2026-05-06-replica-sync-cathedral.md

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

* feat(sync): add kind="replica" path through TransferManager (Phase 3)

Adds the replica branch to the existing book-shaped transfer
infrastructure so dictionary (and future kinds) bundles can flow through
the same queue, retry, and progress UI as book uploads.

Existing book transfer paths remain unchanged. The book-side regression
suite (37 tests in transfer-store.test.ts, 37 in transfer-manager.test.ts)
all stay green.

Store (src/store/transferStore.ts):
- TransferItem gains kind: 'book' | 'replica' (default 'book' on legacy
  persisted rows), replicaKind, replicaId, replicaFiles, replicaBase.
- New addReplicaTransfer(replicaKind, replicaId, displayTitle, type, opts)
  with files + base in opts; auto-computes totalBytes from file sizes.
- New getReplicaTransfer(replicaKind, replicaId, type) lookup.
- getTransferByBookHash filters to kind === 'book' (defensive against
  bookHash="" collisions on replica items).
- restoreTransfers fills kind: 'book' for legacy persisted rows.

Manager (src/services/transferManager.ts):
- queueReplicaUpload / queueReplicaDownload / queueReplicaDelete.
- executeTransfer dispatches by kind to the new executeReplicaTransfer
  (iterates files, calls appService.uploadReplicaFile per file with
  per-file progress aggregation) or the existing executeBookTransfer
  (refactored out, byte-identical behavior).
- Dispatches replica-transfer-complete event on success so stores can
  react (e.g., commit manifest_jsonb to the replica row).

Storage / cloud (src/libs/storage.ts, src/services/cloudService.ts):
- uploadReplicaFile bypasses the book-only File.name smuggling and
  takes an explicit cfp (cloud file path).
- uploadReplicaFileToCloud / downloadReplicaFileFromCloud /
  deleteReplicaBundleFromCloud orchestrate per-file operations under
  ${userId}/Readest/replicas/<kind>/<replicaId>/<filename>.
- replicaCloudKey() centralizes the path-construction rule.
- New CLOUD_REPLICAS_SUBDIR constant.

App service (src/services/appService.ts, src/types/system.ts):
- AppService gains uploadReplicaFile, downloadReplicaFile,
  deleteReplicaBundle (file-level operations; orchestration lives in
  TransferManager).

Tests: 18 new (12 in transfer-store.test.ts, 5 in transfer-manager.test.ts,
1 fixture). Full suite 3674 passing, lint clean. Existing book regression
clean.

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

* feat(sync): dictionary replica adapter + bootstrap (Phase 4, partial)

Lands the safe-to-ship foundation of Phase 4 — adapter logic, registry
bootstrap, and SystemSettings hooks for replica sync. The on-disk
migration of legacy customDictionaries (bundleDir → content-hash id),
the live store wiring, and the Settings → Sync UI are deferred to a
follow-up PR so they can land with real-device QA.

Adapter (src/services/sync/adapters/dictionary.ts):
- dictionaryAdapter: kind='dictionary', schemaVersion=1.
- pack/unpack — only synced subset (name, kind, lang, addedAt,
  unsupported{,Reason}). bundleDir / files / unavailable / deletedAt
  stay per-device or are handled by the tombstone mechanism.
- BinaryCapability.enumerateFiles dispatches by bundle kind:
  - mdict:    mdx + mdd[] + css[]
  - stardict: ifo + idx + dict + syn (skips .idx.offsets / .syn.offsets
              sidecars — those are device-local indices)
  - dict:     dict + index
  - slob:     single .slob file
- primaryDictionaryFile() picks the anchor file per kind for
  partialMD5 hashing.
- computeDictionaryReplicaId(partialMd5, byteSize, sortedFilenames)
  produces a deterministic 32-hex content-hash id used at import time.
- 23 tests cover pack/unpack identity, kind dispatch, file enumeration,
  id determinism, and per-device-field exclusion.

Bootstrap (src/services/sync/replicaBootstrap.ts):
- bootstrapReplicaAdapters() registers all known adapters once at app
  start. Idempotent (safe to call multiple times). Wired into
  EnvContext.tsx so the registry populates on app mount.
- 3 tests cover registration, idempotency, and the PR-1 allowlist.

SystemSettings (src/types/settings.ts, src/services/constants.ts):
- +SyncCategory = 'book' | 'progress' | 'note' | 'dictionary' — typed
  union for the user-facing sync toggles. 'progress' gates the
  existing book-config sync (reading progress); 'note' gates
  annotations; 'book' gates book binaries + metadata; 'dictionary'
  gates the new replica sync. Future replica kinds extend the union.
- +SYNC_CATEGORIES readonly array for UI iteration.
- +syncCategories: Partial<Record<SyncCategory, boolean>> — per-
  category opt-in toggles in DEFAULT_SYSTEM_SETTINGS (default ON for
  all four). UI panel ships in the follow-up.
- +lastSyncedAtReplicas: Record<string, string> — per-kind HLC pull
  cursors (matches replicaSyncManager's CursorStore contract).

Registry type cleanup (src/services/sync/replicaRegistry.ts):
- BinaryCapability.enumerateFiles return shape: localRelPath → lfp
  to match the existing TransferStore.ReplicaTransferFile convention.

Tests: 28 new (23 dict + 3 bootstrap + 2 syncCategories defaults).
Full suite 3702 passing, lint clean. Existing book/config/note sync
paths untouched.

Deferred to PR 1 follow-up (with real-device QA):
- customDictionaryStore migration: rehash legacy uniqueId() bundleDir
  to content-hash id; preserve providerOrder mapping; staged
  .legacy/<old-id>/ backup.
- Wire customDictionaryStore mutations through replicaSyncManager
  (markDirty on add/rename/delete; pull on init).
- Settings → Sync panel: per-category toggles + last-sync timestamps.
- Sync passphrase modal: set / change / forgot flow (lazy first prompt
  on encrypted-field push/pull).
- <CloudReplicaRow> in CustomDictionaries.tsx for "Download from cloud
  (X MB)" affordance.
- Tauri keychain backend for sync passphrase storage.

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

* feat(sync): replicaSync singleton + content-hash id at dict import (Phase 4b)

Two foundation pieces that everything UI-side will sit on top of, both
purely additive — no live store wiring, no behavior change for existing
dictionary imports.

replicaSync singleton (src/services/sync/replicaSync.ts):
- initReplicaSync({deviceId, cursorStore, hlcStore?, client?}) builds
  one ReplicaSyncManager backed by an HlcGenerator with persistence
  wrapped around .next()/.observe(). Idempotent (second init returns
  the existing instance).
- LocalStorageHlcStore (src/libs/hlc-store.ts) snapshots the HLC
  counter under 'readest_replica_hlc' so it survives restart. Falls
  back silently when localStorage is unavailable (private mode, SSR);
  client re-derives via the existing remote max(updated_at_ts) repair
  path. InMemoryHlcStore is the test backend.
- 16 tests (9 hlc-store, 7 replicaSync) covering snapshot persistence,
  restore-on-init, and idempotency.
- Wiring into EnvContext for production deferred to the follow-up that
  also adds the cursor store backed by useSettingsStore.

Content-hash id at dictionary import
(src/services/dictionaries/contentId.ts):
- computeDictionaryContentId(primaryFile, filenames) wraps
  computeDictionaryReplicaId(partialMd5(primary), byteSize,
  sortedFilenames) — the cross-device id used as the replica_id when
  the dict actually pushes/pulls.
- Wired into all four import paths in dictionaryService.ts:
  - stardict primary = .ifo (small text, partialMD5 ≈ full hash)
  - mdict primary    = .mdx (body)
  - dict primary     = .dict.dz (gzipped body)
  - slob primary     = .slob (single-file bundle)
  ImportedDictionary gains contentId?: string. Optional for backwards
  compat; legacy bundles without contentId are flagged as
  "needs rehash before sync" by the upcoming store-wiring follow-up.
- 6 tests cover identity determinism, byteSize sensitivity, filename-
  set sensitivity, and order-independence.

Tests: 22 new (9 + 7 + 6). Full suite 3724 passing, lint clean.
Existing dictionary import flow unchanged for users — contentId is an
additional field, not a replacement for the bundleDir-based id.

Deferred to follow-up (with on-device QA):
- Production cursor store backed by useSettingsStore +
  appService.saveSettings.
- EnvContext call to initReplicaSync after appService boot.
- customDictionaryStore mutation hooks → replicaSyncManager.markDirty.
- Legacy bundleDir → contentId migration with .legacy/ backup.

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

* docs(sync): land replica-sync design plan in repo

Moves the plan document that drove this PR's foundation work
(`~/.claude/plans/vivid-orbiting-thimble.md`) into the project tree at
`apps/readest-app/.claude/plans/` so reviewers and future contributors
can read it alongside the code without leaving the repo.

The plan went through three review passes — Codex (19 findings, all
absorbed), CEO/scope review (mode SCOPE EXPANSION; encrypted secrets
pulled forward to v1, "private-only forever" posture lock), and eng
review (FULL_REVIEW mode, 16 findings absorbed). The full review trail
lives in the file's `## GSTACK REVIEW REPORT` section at the bottom.

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

* docs(crdt): point README at in-repo plan path

Now that vivid-orbiting-thimble.md lives at
apps/readest-app/.claude/plans/, the README link should point there
rather than at the home-dir copy that no longer exists.

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

* refactor(sync): rename src/libs files to camelCase per project convention

Test files renamed in lockstep. Imports + comment references updated
across cloudService, storage, transferManager, replicaSync,
replicaSyncManager, /api/sync/replicas, and all four test files. No
behavior change.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:50:15 +02:00
Huang Xin e7564ffc27 fix(docker): correct Kong gateway port for client build arg (#4046)
The web client's NEXT_PUBLIC_SUPABASE_URL build arg pointed at port 7000,
but Kong is exposed on KONG_HTTP_PORT (8000 by default), so browser-side
Supabase calls failed in the self-hosted Docker setup.

Closes #4035

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:10:16 +02:00
Huang Xin d1e7b4902c feat(share): time-limited share links with cfi-aware imports (#4037)
Add a Share Book feature that generates an expiring HTTPS share URL plus a
parallel readest://share/{token} deep link. Recipients land on /s/{token},
where logged-in users can one-tap "Add to my library" (R2 server-side
byte-copy) and anonymous users download the book or open it in the app.
Sharers manage active links from a dedicated "Manage Shared Links" panel
under user settings.

Highlights:
- 9 new App Router endpoints under /api/share (create, [token], cover,
  og.png, download, download/confirm, import, revoke, list).
- /s landing page with branded next/og chat unfurl image and SSR auth-cookie
  detection so logged-in recipients see "Add to my library" as the primary
  action without layout shift.
- Server-side R2 byte-copy for /import preserves the project's existing
  invariant that every files.file_key is prefixed with its row's user_id;
  stats / purge / delete / download routes work unchanged. URL-encodes the
  copy source so titles with spaces or '&' don't break the copy.
- Universal 7-day expiry cap, no tier differentiation, no "never". DMCA-risk
  reduction. Picker defaults to 3 days.
- Position-aware shares: "Share current page" toggle (off by default for
  privacy) attaches the sharer's CFI; recipient lands at the same paragraph.
- Per-user 50-share cap, rate limiting via Cache-Control: no-store on
  token-bearing responses, atomic SQL increment for download_count via a
  SECURITY DEFINER function so the public confirm beacon stays safe under
  concurrent fire.
- Soft revocation: presigned download URLs (5-min TTL) cannot be cancelled
  before TTL; documented as accepted v1 behavior.
- token + token_hash hybrid storage: public endpoints look up by hash and
  never select the raw token, so accidental SELECT-* leakage on a public
  route can't expose the bearer credential.
- Mobile / desktop Tauri share via tauri-plugin-sharekit; web falls back to
  navigator.share with a clipboard fallback when no native share method
  exists. Share-sheet dismissal no longer silently copies.

UI:
- New Dialog with a settings-card group: iOS-style segmented duration picker
  + toggle slider for "Share current page", on a single row each.
- Reader top-bar Share button, library context-menu Share entry, manage-
  shares list with cover thumbnails and overflow menu.
- New <SegmentedControl> primitive in src/components for reuse.

Coverage:
- Unit tests for token utils + URL parser (20 new tests, full suite at 3445).
- 31 locales translated for all new strings; en plurals hand-added per the
  project's hand-curated en convention.

DB migration in docker/volumes/db/migrations/002_add_book_shares.sql adds
the book_shares table, RLS policies, and the increment_book_share_download
RPC. Migration is idempotent.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:03:35 +02:00
Lex Moulton baee85e7c8 feat(rsvp): sync reading position to cloud via book_configs (#3801) 2026-04-09 15:52:49 +02:00
Huang Xin 29df8522fa chore(bump): bump Tauri to the latest version (#3716) 2026-04-01 10:06:23 +02:00
Huang Xin 76b239f382 feat(koplugin): add support for annotation sync (#3579)
Add bidirectional annotation/highlight sync between KOReader and Readest:

- Add xpointer0/xpointer1 fields to BookNote and DBBookNote types for
  KOReader XPointer positions alongside Readest's CFI format
- Extend transform layer to pass through xpointer fields to/from DB
- Convert CFI→XPointer on push and XPointer→CFI on pull in useNotesSync,
  discarding notes that fail conversion
- Support KOReader's text()[K].N indexed text node format in xcfi.ts for
  paragraphs with inline elements (e.g. <a> page anchors)
- Generate KOReader-compatible XPointers: text().N for single text nodes,
  text()[K].N only when multiple direct text nodes exist
- Skip cfi-inert elements (injected by Readest at runtime) in XPointer
  path building and resolution
- Map highlight colors between KOReader and Readest color systems
- Implement KOReader plugin annotation push/pull with deterministic IDs,
  auto-sync on document open/close, and UIManager refresh on pull
- Refactor koplugin into focused modules: syncauth, syncconfig,
  syncannotations, selfupdate

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:43:51 +01:00
Azeem Bande-Ali 34ad6e6749 fix(selfhost): update docker db schema to match FileRecords (#3527)
In #2636, FileRecord was defined to have updated_at  field
which is used when it is accessed from the database. But the
local dev setup is missing this field.

This diff adds an updated_at column to match the expectation
2026-03-13 05:15:55 +01:00
Huang Xin c533da498d feat(annotator): add page number for annotations, closes #3082 (#3405)
* feat(annotator): add page number in annotation

* feat: add page number for annotations, closes #3082
2026-02-28 08:08:56 +01:00
Aniket Kotal eec2c39f19 feat(docker/podman): self-hosting with docker/podman compose (#3312)
* initial files

* added testing files

* removed unused files

* cleaned additional mounts

* fixed sql init

* removed more unused files

* moved to docker folder

* revert package.json

* gitignore update

* env example comments and compose necessary healthcheck

* ghcr package impl

* updated dockerfile steps  for layer caching

* added development-stage to dockerfile to dev environment

* added documentation on how to use dockerfile and compose.yml

* fixed prettier issues

* fixed image tag

* removed workflow for later
2026-02-18 14:28:10 +01:00