Files
cinny/src/app/hooks/useHydrateMsgDrafts.ts
T
jaredandClaude Opus 5 6e4c4bc795 fix(security): composer drafts no longer survive logout or cross accounts
draft-msg-<roomId> was unscoped and deliberately skipped on logout, then
hydrated into whoever logged in next. Wipe drafts in clearPlaintextCaches,
and only hydrate a draft whose stored userId matches the current user.
Drafts written before this change carry no userId and are dropped on
first load (a one-time loss of unsent drafts, accepted for the leak fix).

Fixes #41

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 14:48:34 -04:00

67 lines
2.6 KiB
TypeScript

import { useEffect } from 'react';
import { useStore } from 'jotai';
import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts';
import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft';
import { useMatrixClient } from './useMatrixClient';
/**
* On startup, pre-fill the per-room message-draft atoms from their localStorage
* persistence (`draft-msg-<roomId>`). RoomInput only restores a draft into its
* atom when that room's composer mounts, so without this a background room's
* persisted draft wouldn't reflect in the room-nav draft indicator until the
* room is opened. Runs once; only touches the same atoms RoomInput restores from
* (identical content), so composer restore is unaffected.
*
* Thread drafts (key contains `::`) are skipped — the nav indicator is room-level.
*
* [Gitea #41] Drafts are stored as `{ userId, nodes }` (RoomInput's persist
* path) so a draft written by a different account never gets hydrated into the
* currently logged-in user's session. A legacy draft (stored as a bare array,
* pre-dating user-scoping) has no userId to check, so it's treated as foreign
* and dropped rather than trusted.
*/
export function useHydrateMsgDrafts(): void {
const store = useStore();
const mx = useMatrixClient();
useEffect(() => {
const userId = mx.getUserId();
let keys: string[];
try {
keys = Object.keys(localStorage);
} catch {
return;
}
keys.forEach((key) => {
if (!key.startsWith(DRAFT_MSG_KEY_PREFIX)) return;
const draftKey = key.slice(DRAFT_MSG_KEY_PREFIX.length);
// Skip thread drafts (`<roomId>::<$threadRootEventId>`) — room-level only.
// Match `::$` specifically so an IPv6-literal server name (e.g. `[::1]`)
// in a roomId isn't mistaken for a thread key.
if (!draftKey || draftKey.includes('::$')) return;
try {
const stored = localStorage.getItem(key);
if (!stored) return;
const parsed = JSON.parse(stored);
const foreign =
!parsed ||
typeof parsed !== 'object' ||
Array.isArray(parsed) ||
parsed.userId !== userId;
if (foreign) {
// Another account's (or a pre-scoping legacy) draft — never hydrate it,
// and drop it so it can't resurface for the next login either.
localStorage.removeItem(key);
return;
}
const nodes = parsed.nodes;
if (Array.isArray(nodes) && hasMsgDraft(nodes)) {
store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes);
}
} catch {
// Ignore a malformed stored draft.
}
});
}, [store, mx]);
}