Files
readest/apps/readest-app/src/services/sync/replicaBootstrap.ts
T
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

72 lines
3.2 KiB
TypeScript

import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { useCustomFontStore } from '@/store/customFontStore';
import { useCustomTextureStore } from '@/store/customTextureStore';
import { dictionaryAdapter, DICTIONARY_KIND } from './adapters/dictionary';
import { fontAdapter, FONT_KIND } from './adapters/font';
import { textureAdapter, TEXTURE_KIND } from './adapters/texture';
import { opdsCatalogAdapter } from './adapters/opdsCatalog';
import { settingsAdapter } from './adapters/settings';
import { getReplicaPersistEnv } from './replicaPersist';
import { getReplicaAdapter, registerReplicaAdapter } from './replicaRegistry';
import { registerReplicaDownloadHandler } from './replicaTransferIntegration';
import type { ReplicaAdapter } from './replicaRegistry';
const KNOWN_ADAPTERS: ReplicaAdapter<unknown>[] = [
dictionaryAdapter as unknown as ReplicaAdapter<unknown>,
fontAdapter as unknown as ReplicaAdapter<unknown>,
textureAdapter as unknown as ReplicaAdapter<unknown>,
// Metadata-only — no binary download handler needed.
opdsCatalogAdapter as unknown as ReplicaAdapter<unknown>,
// Bundled scalar settings — singleton row, no binary.
settingsAdapter as unknown as ReplicaAdapter<unknown>,
];
let didBootstrap = false;
export const bootstrapReplicaAdapters = (): void => {
if (didBootstrap) return;
for (const adapter of KNOWN_ADAPTERS) {
if (getReplicaAdapter(adapter.kind)) continue;
registerReplicaAdapter(adapter);
}
// Per-kind download-completion handlers — fired by
// replicaTransferIntegration once binaries are on disk. Each store
// exposes a markAvailable* method that clears the placeholder
// `unavailable` flag set by the pull orchestrator.
registerReplicaDownloadHandler(DICTIONARY_KIND, (replicaId) => {
useCustomDictionaryStore.getState().markAvailableByContentId(replicaId);
});
// Fonts need more than the unavailable flag cleared: the binary must
// be loaded into a blob URL and the @font-face rule injected, the
// same plumbing manual import does in CustomFonts.tsx. Without this
// the auto-downloaded font appears in the UI but renders in a
// fallback face. Falls back to flag-only when persist env hasn't
// landed yet (extremely early boot).
registerReplicaDownloadHandler(FONT_KIND, (replicaId) => {
const env = getReplicaPersistEnv();
if (!env) {
useCustomFontStore.getState().markAvailableByContentId(replicaId);
return;
}
void useCustomFontStore.getState().activateFontByContentId(env, replicaId);
});
// Textures: mark available + load the file into a blob URL so the
// panel grid renders the swatch and `applyTexture` can mount it
// without re-reading disk. Mounting only happens when the user
// selects the texture (via applyTexture), so no automatic mount
// here. Falls back to flag-only when persist env hasn't landed yet.
registerReplicaDownloadHandler(TEXTURE_KIND, (replicaId) => {
const env = getReplicaPersistEnv();
if (!env) {
useCustomTextureStore.getState().markAvailableByContentId(replicaId);
return;
}
void useCustomTextureStore.getState().activateTextureByContentId(env, replicaId);
});
didBootstrap = true;
};
export const __resetBootstrapForTests = (): void => {
didBootstrap = false;
};