668bdaad7d
Web fixes from the Wave-2 bug-hunt (findings in LOTUS_TODO): - F1 (security): wipe the decrypted-plaintext search index on SERVER-FORCED logout too (token expiry / remote sign-out) — only manual logout did before. F4: the delete no longer reports success while onblocked (waits, 3s cap). - M1/M2 (data-loss): useBookmarks + useUserNotes account-data writes are now serialized at MODULE scope (single queue + latestRef per client, echo-driven), fixing the cross-instance lost-update clobber (useBookmarks mounts per message row, so a per-instance queue was insufficient — caught in review). - M6: room-history export gets a 200-page cap + Cancel + unmount-abort + correct date-range early-break (raw paginated ts). M4: image compression skips PNG (was flattening transparency to black), bakes EXIF orientation via createImageBitmap, .jpg-renames, and falls back to the original on decode failure instead of dropping the file. M5: MediaGallery lightbox opens the right item (shared thumb guard). M8: audio speed survives async decrypt. - Desktop web wiring: D2 badge sums leaf rooms only (space double-count, like the favicon fix); D3 useTauriDnd re-hydrates from get_tray_dnd on mount; D5 updater has a terminal state. Reviewed; M7 reverted (past-time clamp is an intentional, tested contract). tsc/eslint/prettier clean, build OK, 678 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
/**
|
|
* 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';
|
|
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<IDBDatabase | null> | null = null;
|
|
|
|
const openDb = (): Promise<IDBDatabase | null> => {
|
|
if (dbPromise) return dbPromise;
|
|
dbPromise = new Promise<IDBDatabase | null>((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<void> =>
|
|
new Promise<void>((resolve, reject) => {
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
tx.onabort = () => reject(tx.error);
|
|
});
|
|
|
|
/** Upsert message rows. No-op on empty input or when IDB is unavailable. */
|
|
export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
|
|
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));
|
|
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<SearchCacheRow[]> => {
|
|
const db = await openDb();
|
|
if (!db) return [];
|
|
try {
|
|
return await new Promise<SearchCacheRow[]>((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<SearchCacheRow[]> => {
|
|
const db = await openDb();
|
|
if (!db) return [];
|
|
try {
|
|
return await new Promise<SearchCacheRow[]>((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<number> => {
|
|
const db = await openDb();
|
|
if (!db) return 0;
|
|
try {
|
|
return await new Promise<number>((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<SearchCacheCoverage | null> => {
|
|
const db = await openDb();
|
|
if (!db) return null;
|
|
try {
|
|
return await new Promise<SearchCacheCoverage | null>((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<void> => {
|
|
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<Pick<SearchCacheRow, 'ts'>>,
|
|
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<void> => {
|
|
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<T>,
|
|
cached: ReadonlyArray<T>,
|
|
): T[] => {
|
|
const byId = new Map<string, T>();
|
|
// 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),
|
|
);
|
|
};
|
|
|
|
export const clearRoom = async (roomId: string): Promise<void> => {
|
|
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<void> => {
|
|
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.
|
|
*/
|
|
export const deleteSearchCacheDatabase = async (): Promise<void> => {
|
|
try {
|
|
const existing = dbPromise ? await dbPromise : null;
|
|
if (existing) existing.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
dbPromise = null;
|
|
return new Promise<void>((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();
|
|
}
|
|
});
|
|
};
|