/** * P4-8 — persistent encrypted-search cache (raw IndexedDB, no new deps). * * The homeserver cannot search E2EE message content, so encrypted-room search * only ever covers what the client has paginated + decrypted this session. This * module persists a local plaintext index so coverage survives reloads. * * PRIVACY: this stores decrypted plaintext at rest. It is opt-in (default OFF), * clearable, and wiped on logout via `deleteSearchCacheDatabase()`. * * Resilience contract: every entry point swallows IndexedDB errors and behaves * as a cache-miss. Nothing here ever throws to the UI. */ const DB_NAME = 'lotus-search-cache'; const DB_VERSION = 1; const MESSAGES_STORE = 'messages'; const COVERAGE_STORE = 'coverage'; // Cap cached rows per room so the on-disk index can't grow unbounded over a // long-lived session. When a room exceeds this, the oldest rows (by ts) are // evicted on write. ~5k small rows/room is generous search history; the coverage // window is intentionally left claiming the evicted tail so we don't re-fetch + // re-evict it forever (Clear cached index / logout still wipe everything). const MAX_ROWS_PER_ROOM = 5000; /** How many of a room's rows to evict to bring it back to the cap (0 if under). */ export const evictCount = (currentCount: number, max = MAX_ROWS_PER_ROOM): number => Math.max(0, currentCount - max); const ROOM_TS_INDEX = 'roomTs'; /** A single cached, decrypted message row. Keyed on `[roomId, eventId]`. */ export type SearchCacheRow = { roomId: string; eventId: string; ts: number; sender: string; body: string; formattedBody?: string; pollText?: string; }; /** Per-room coverage stats for the "X / Y cached" UI counters. */ export type SearchCacheCoverage = { roomId: string; oldestTs: number; newestTs: number; count: number; }; // A key range that matches every `[roomId, *]` entry in a composite-key store // or `[roomId, ts]` index: an empty array sorts after all other key types, so // `[roomId]` .. `[roomId, []]` brackets the whole room partition. const roomRange = (roomId: string): IDBKeyRange => IDBKeyRange.bound([roomId], [roomId, []]); let dbPromise: Promise | null = null; // Gitea #45 — `deleteSearchCacheDatabase()` used to resolve as soon as its // bounded `onblocked` wait elapsed, even though another tab still had the DB // open, so the delete stayed silently queued while that tab could keep // `put`-ing fresh decrypted rows (or immediately reopen the DB once the // delete eventually landed). `closed` is broadcast to every tab the moment a // wipe starts: once set, `openDb()` refuses to (re)open a handle — including // for an already in-flight `saveRoomIndex()` awaiting its next IDB // round-trip — so nothing can repopulate the DB out from under the delete. let closed = false; const LOGOUT_CHANNEL_NAME = 'lotus-logout'; const LOGOUT_MESSAGE = 'lotus-logout'; /** Stop touching the DB and release our handle. Idempotent, never throws. */ const handleLogoutSignal = (): void => { closed = true; const pending = dbPromise; dbPromise = null; if (pending) { pending.then((db) => db?.close()).catch(() => undefined); } }; const logoutChannel: BroadcastChannel | null = (() => { try { if (typeof BroadcastChannel === 'undefined') return null; const channel = new BroadcastChannel(LOGOUT_CHANNEL_NAME); channel.onmessage = (ev: MessageEvent) => { if (ev.data === LOGOUT_MESSAGE) handleLogoutSignal(); }; // Node's BroadcastChannel (unlike the browser's) keeps the event loop // alive while open, which would hang `node --test`. unref() is a // Node-only extension — no-op via optional chaining in the browser. (channel as unknown as { unref?: () => void }).unref?.(); return channel; } catch { return null; } })(); const openDb = (): Promise => { // Once logout has broadcast a wipe, this module must never reopen the DB — // otherwise a write racing the delete could recreate it right after. if (closed) return Promise.resolve(null); if (dbPromise) return dbPromise; dbPromise = new Promise((resolve) => { try { if (typeof indexedDB === 'undefined') { resolve(null); return; } const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(MESSAGES_STORE)) { const store = db.createObjectStore(MESSAGES_STORE, { keyPath: ['roomId', 'eventId'], }); store.createIndex(ROOM_TS_INDEX, ['roomId', 'ts']); } if (!db.objectStoreNames.contains(COVERAGE_STORE)) { db.createObjectStore(COVERAGE_STORE, { keyPath: 'roomId' }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => { dbPromise = null; // allow a later retry resolve(null); }; req.onblocked = () => { dbPromise = null; resolve(null); }; } catch { dbPromise = null; resolve(null); } }); return dbPromise; }; /** Resolve once a write transaction commits (or reject/abort → caller swallows). */ const awaitTx = (tx: IDBTransaction): Promise => new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); /** * Within an open readwrite tx, delete the oldest rows of `roomId` (ascending * `[roomId, ts]` index) until it's back under the cap. Self-chains IDB requests * so the transaction stays alive — never awaits a non-IDB promise mid-tx (which * would let the transaction auto-commit early). */ const pruneRoom = (store: IDBObjectStore, roomId: string): void => { const index = store.index(ROOM_TS_INDEX); const countReq = index.count(roomRange(roomId)); countReq.onsuccess = () => { let remaining = evictCount(countReq.result); if (remaining <= 0) return; const cursorReq = index.openCursor(roomRange(roomId), 'next'); // oldest first cursorReq.onsuccess = () => { const cursor = cursorReq.result; if (!cursor || remaining <= 0) return; cursor.delete(); remaining -= 1; cursor.continue(); }; }; }; /** Upsert message rows. No-op on empty input or when IDB is unavailable. */ export const putRows = async (rows: SearchCacheRow[]): Promise => { if (rows.length === 0) return; const db = await openDb(); if (!db) return; try { const tx = db.transaction(MESSAGES_STORE, 'readwrite'); const store = tx.objectStore(MESSAGES_STORE); rows.forEach((row) => store.put(row)); // Bound growth: prune each room this batch touched back to the cap. new Set(rows.map((row) => row.roomId)).forEach((roomId) => pruneRoom(store, roomId)); await awaitTx(tx); } catch { // Cache write failures must never surface to the UI. } }; /** All cached rows for a room, ordered oldest→newest by the `[roomId, ts]` index. */ export const queryRoom = async (roomId: string): Promise => { const db = await openDb(); if (!db) return []; try { return await new Promise((resolve, reject) => { const tx = db.transaction(MESSAGES_STORE, 'readonly'); const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX); const req = index.getAll(roomRange(roomId)); req.onsuccess = () => resolve((req.result as SearchCacheRow[]) ?? []); req.onerror = () => reject(req.error); }); } catch { return []; } }; /** Cursor variant: stream a room's rows through a matcher, collecting hits. */ export const searchRoom = async ( roomId: string, matcher: (row: SearchCacheRow) => boolean, ): Promise => { const db = await openDb(); if (!db) return []; try { return await new Promise((resolve, reject) => { const hits: SearchCacheRow[] = []; const tx = db.transaction(MESSAGES_STORE, 'readonly'); const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX); const req = index.openCursor(roomRange(roomId)); req.onsuccess = () => { const cursor = req.result; if (!cursor) { resolve(hits); return; } const row = cursor.value as SearchCacheRow; if (matcher(row)) hits.push(row); cursor.continue(); }; req.onerror = () => reject(req.error); }); } catch { return []; } }; /** Number of cached rows for a room. */ export const countRoom = async (roomId: string): Promise => { const db = await openDb(); if (!db) return 0; try { return await new Promise((resolve, reject) => { const tx = db.transaction(MESSAGES_STORE, 'readonly'); const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX); const req = index.count(roomRange(roomId)); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } catch { return 0; } }; export const getCoverage = async (roomId: string): Promise => { const db = await openDb(); if (!db) return null; try { return await new Promise((resolve, reject) => { const tx = db.transaction(COVERAGE_STORE, 'readonly'); const req = tx.objectStore(COVERAGE_STORE).get(roomId); req.onsuccess = () => resolve((req.result as SearchCacheCoverage) ?? null); req.onerror = () => reject(req.error); }); } catch { return null; } }; export const putCoverage = async (coverage: SearchCacheCoverage): Promise => { const db = await openDb(); if (!db) return; try { const tx = db.transaction(COVERAGE_STORE, 'readwrite'); tx.objectStore(COVERAGE_STORE).put(coverage); await awaitTx(tx); } catch { // ignore } }; /** * Pure helper: fold a batch of rows into a coverage record, widening the * `oldestTs`/`newestTs` window against any previous coverage. `count` is * supplied by the caller (authoritative store count) so dedup across sessions * is handled correctly. Exported for testing without IDB. */ export const computeCoverage = ( roomId: string, rows: ReadonlyArray>, count: number, previous?: SearchCacheCoverage | null, ): SearchCacheCoverage => { let oldestTs = previous?.oldestTs ?? Number.POSITIVE_INFINITY; let newestTs = previous?.newestTs ?? Number.NEGATIVE_INFINITY; rows.forEach((row) => { if (row.ts < oldestTs) oldestTs = row.ts; if (row.ts > newestTs) newestTs = row.ts; }); if (!Number.isFinite(oldestTs)) oldestTs = 0; if (!Number.isFinite(newestTs)) newestTs = 0; return { roomId, oldestTs, newestTs, count }; }; /** * Convenience persist path used by the search hook: upsert a batch of rows for * a room, then recompute + store the room's coverage from the authoritative * store count. Fire-and-forget; never throws. */ export const saveRoomIndex = async (roomId: string, rows: SearchCacheRow[]): Promise => { if (rows.length === 0) return; await putRows(rows); const [count, previous] = await Promise.all([countRoom(roomId), getCoverage(roomId)]); await putCoverage(computeCoverage(roomId, rows, count, previous)); }; /** * Pure helper: merge in-memory result items with cache-derived result items, * deduping by `event.event_id` (in-memory wins), sorted by `origin_server_ts` * descending. Generic over the minimal shape it reads so it is fully testable * without matrix-js-sdk types. Exported for testing. */ export const mergeSearchResults = < T extends { event: { event_id: string; origin_server_ts?: number } }, >( memory: ReadonlyArray, cached: ReadonlyArray, ): T[] => { const byId = new Map(); // Seed with cached, then let in-memory overwrite so in-memory always wins. cached.forEach((item) => byId.set(item.event.event_id, item)); memory.forEach((item) => byId.set(item.event.event_id, item)); return Array.from(byId.values()).sort( (a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0), ); }; /** * Delete a single cached row, e.g. because its event was redacted. Gitea #14: * without this, a redaction only ever removed the in-memory hit — the * decrypted plaintext stayed in IndexedDB forever. */ export const deleteRow = async (roomId: string, eventId: string): Promise => { const db = await openDb(); if (!db) return; try { const tx = db.transaction(MESSAGES_STORE, 'readwrite'); tx.objectStore(MESSAGES_STORE).delete([roomId, eventId]); await awaitTx(tx); } catch { // ignore } }; export const clearRoom = async (roomId: string): Promise => { const db = await openDb(); if (!db) return; try { const tx = db.transaction([MESSAGES_STORE, COVERAGE_STORE], 'readwrite'); tx.objectStore(MESSAGES_STORE).delete(roomRange(roomId)); tx.objectStore(COVERAGE_STORE).delete(roomId); await awaitTx(tx); } catch { // ignore } }; export const clearAll = async (): Promise => { const db = await openDb(); if (!db) return; try { const tx = db.transaction([MESSAGES_STORE, COVERAGE_STORE], 'readwrite'); tx.objectStore(MESSAGES_STORE).clear(); tx.objectStore(COVERAGE_STORE).clear(); await awaitTx(tx); } catch { // ignore } }; /** * Drop the entire on-disk database. Wired into the logout path by the * coordinator (initMatrix) so no decrypted plaintext lingers after sign-out. * Closes any open handle first so the delete is not blocked. Never throws. * * Gitea #45 — before touching IDB at all, broadcast the wipe on * `lotus-logout` so every other tab closes its handle and stops writing * (`handleLogoutSignal`/`closed` above); that tab's in-flight * `saveRoomIndex()` short-circuits on its next IDB call instead of * repopulating a DB we're about to delete or racing back in right after. The * bounded `onblocked` wait stays as a last-resort fallback (a tab that hasn't * processed the broadcast yet, or a browser without BroadcastChannel), not * the primary coordination mechanism. */ export const deleteSearchCacheDatabase = async (): Promise => { closed = true; try { logoutChannel?.postMessage(LOGOUT_MESSAGE); } catch { // ignore } try { const existing = dbPromise ? await dbPromise : null; if (existing) existing.close(); } catch { // ignore } dbPromise = null; return new Promise((resolve) => { try { if (typeof indexedDB === 'undefined') { resolve(); return; } const req = indexedDB.deleteDatabase(DB_NAME); let settled = false; const done = () => { if (!settled) { settled = true; resolve(); } }; req.onsuccess = done; req.onerror = done; req.onblocked = () => { // Another tab still holds the DB open, so the delete is QUEUED, not done — // resolving now would report a wipe that hasn't happened (plaintext still // on disk). Wait for the real onsuccess (fires once the other tab closes; // cross-tab logout reloads it shortly), but cap the wait so logout can't // hang forever if a tab never releases. setTimeout(done, 3000); }; } catch { resolve(); } }); }; /** * Pure decision for the boot-time completion wipe (Gitea #45): a delete that * lost the `onblocked` race leaves `lotus-search-cache` on disk even though * the user is signed out. Re-run the wipe next boot, but only when there is * no session (never nuke a live, signed-in cache) and only once per boot * (the caller's own re-checks — e.g. route changes — must not repeat it). * Exported for testing without a DOM/session; the actual boot wiring lives in * initMatrix.ts. */ export const shouldRunBootCleanup = (hasSession: boolean, alreadyRan: boolean): boolean => !hasSession && !alreadyRan;