diff --git a/src/app/state/plaintextCaches.test.ts b/src/app/state/plaintextCaches.test.ts index a4c688e1f..aa0630771 100644 --- a/src/app/state/plaintextCaches.test.ts +++ b/src/app/state/plaintextCaches.test.ts @@ -6,7 +6,9 @@ import assert from 'node:assert/strict'; // hoist above the mock). const removed: string[] = []; const store = new Map(); -(globalThis as { localStorage?: unknown }).localStorage = { +// A Proxy so `Object.keys(localStorage)` (used by the prefix sweeps) sees the +// stored keys, like the real Storage object. +const api = { getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => { store.set(k, v); @@ -16,6 +18,13 @@ const store = new Map(); store.delete(k); }, }; +(globalThis as { localStorage?: unknown }).localStorage = new Proxy(api, { + ownKeys: () => Array.from(store.keys()), + getOwnPropertyDescriptor: (target, key) => + typeof key === 'string' && store.has(key) + ? { value: store.get(key), enumerable: true, configurable: true, writable: true } + : Object.getOwnPropertyDescriptor(target, key), +}); const { clearPlaintextCaches } = await import('./plaintextCaches'); @@ -56,15 +65,19 @@ test('clearPlaintextCaches clears the per-user nav-path store only when given a assert.ok(removed.includes('navToActivePath@me:server'), 'nav path cleared with userId'); }); -test('clearPlaintextCaches does NOT touch drafts or session keys', () => { +test('clearPlaintextCaches wipes drafts (#41) and the status message (#204) but not session keys', () => { store.clear(); store.set('draft-msg-!room:server', '{"body":"unsent"}'); + store.set('lotus-status-msg-@me:server', 'at the dentist'); + store.set('lotus-status-expiry-@me:server', '123'); store.set('cinny_session', '{"accessToken":"x"}'); removed.length = 0; clearPlaintextCaches('@me:server'); - assert.ok(!removed.includes('draft-msg-!room:server'), 'draft preserved (N98)'); + assert.ok(removed.includes('draft-msg-!room:server'), 'draft cleared'); + assert.ok(removed.includes('lotus-status-msg-@me:server'), 'status message cleared'); + assert.ok(removed.includes('lotus-status-expiry-@me:server'), 'status expiry cleared'); assert.ok(!removed.includes('cinny_session'), 'session key not this module’s concern'); - assert.ok(store.has('draft-msg-!room:server')); + assert.ok(store.has('cinny_session')); }); diff --git a/src/app/state/plaintextCaches.ts b/src/app/state/plaintextCaches.ts index ef3893911..45b6e04f3 100644 --- a/src/app/state/plaintextCaches.ts +++ b/src/app/state/plaintextCaches.ts @@ -54,12 +54,36 @@ const clearMsgDrafts = (): void => { * bookmarks, user notes, status presets — themselves plaintext) → wiped by * `mx.clearStores()` on both logout paths * - the opt-in encrypted-search index (IndexedDB) → `deleteSearchCacheDatabase()` - * - the presence status message (`lotus-status-msg-*`) is deliberately - * preserved across a normal logout; clearing it is a separate product decision + * - (the presence status message + expiry, `lotus-status-msg-*` / + * `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are + * swept with the rest — the server-side presence status survives, so a + * re-login loses nothing) * - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed * nav/space categories, `cinny_oidc_dynamic_clients`) is treated as * preferences, not swept here */ +/** + * [Gitea #204] The local mirror of the user's status message (+ its expiry) is + * PII in plaintext; the authoritative copy lives in server presence. + */ +const clearStatusMessage = (): void => { + let keys: string[]; + try { + keys = Object.keys(localStorage); + } catch { + return; + } + keys.forEach((key) => { + if (key.startsWith('lotus-status-msg-') || key.startsWith('lotus-status-expiry-')) { + try { + localStorage.removeItem(key); + } catch { + // best-effort + } + } + }); +}; + export const clearPlaintextCaches = (userId?: string): void => { clearTranslationCache(); clearScheduledMessages(); @@ -68,5 +92,6 @@ export const clearPlaintextCaches = (userId?: string): void => { clearRecentGifs(); clearRecentStickers(); clearMsgDrafts(); + clearStatusMessage(); if (userId) clearNavToActivePathStore(userId); };