fix(wave-2): audit fixes — account-data races, search-cache wipe, export, media
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>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text } from 'folds';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Text } from 'folds';
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
@@ -16,6 +16,12 @@ const FORMAT_LABELS: Record<ExportFormat, string> = {
|
||||
html: 'HTML',
|
||||
};
|
||||
|
||||
const PAGE_LIMIT = 100;
|
||||
// Hard cap on back-pagination requests. Without a fromDate, "export all" would
|
||||
// otherwise decrypt and hold every message in the room, hammering the server and
|
||||
// risking an OOM/freeze with no way to stop. 200 pages × 100 ≈ 20,000 events.
|
||||
const MAX_EXPORT_PAGES = 200;
|
||||
|
||||
type ExportRoomHistoryProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
@@ -30,11 +36,28 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
const [toDate, setToDate] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportCount, setExportCount] = useState(0);
|
||||
const [notice, setNotice] = useState('');
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
cancelledRef.current = true;
|
||||
}, []);
|
||||
|
||||
// Stop an in-flight export if the panel unmounts (closing settings mid-export
|
||||
// would otherwise keep paginating + decrypting in the background).
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelledRef.current = true;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
if (exporting) return;
|
||||
cancelledRef.current = false;
|
||||
setExporting(true);
|
||||
setExportCount(0);
|
||||
setNotice('');
|
||||
|
||||
try {
|
||||
const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
|
||||
@@ -55,6 +78,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
const seen = new Set<string>();
|
||||
const timeline = room.getLiveTimeline();
|
||||
let canLoadMore = true;
|
||||
// Track the oldest collected timestamp incrementally so the fromTs check
|
||||
// doesn't rescan the whole `collected` array on every pagination step.
|
||||
let oldestTs = Number.POSITIVE_INFINITY;
|
||||
// Oldest RAW message ts paginated (tracked BEFORE the fromTs filter). The
|
||||
// date-range early-break must use this — oldestTs only ever holds collected
|
||||
// events (all >= fromTs), so it can never fall below fromTs and the export
|
||||
// would over-paginate to the page cap and show a misleading "truncated".
|
||||
let oldestRawTs = Number.POSITIVE_INFINITY;
|
||||
|
||||
const addEvents = async (events: ReturnType<typeof timeline.getEvents>) => {
|
||||
for (const ev of events) {
|
||||
@@ -70,12 +101,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
if (ev.getType() !== EventType.RoomMessage) continue;
|
||||
if (ev.isDecryptionFailure()) continue;
|
||||
const ts = ev.getTs();
|
||||
if (ts < oldestRawTs) oldestRawTs = ts;
|
||||
if (fromTs !== null && ts < fromTs) continue;
|
||||
if (toTs !== null && ts > toTs) continue;
|
||||
const content = ev.getContent();
|
||||
const body: string = content.body ?? '';
|
||||
const msgtype: string = content.msgtype ?? '';
|
||||
if (!body) continue;
|
||||
if (ts < oldestTs) oldestTs = ts;
|
||||
collected.push({
|
||||
ts,
|
||||
sender: ev.getSender() ?? '',
|
||||
@@ -89,25 +122,40 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
|
||||
await addEvents(timeline.getEvents());
|
||||
|
||||
// Paginate backwards until start or date range exceeded
|
||||
// Paginate backwards until start, date range exceeded, cap hit, or cancel
|
||||
let pageCount = 0;
|
||||
let truncated = false;
|
||||
let cancelled = false;
|
||||
while (canLoadMore) {
|
||||
// If we have a fromTs, check whether the oldest collected event is already
|
||||
// before it — if so we don't need to paginate further.
|
||||
if (fromTs !== null && collected.length > 0) {
|
||||
const oldestTs = Math.min(...collected.map((r) => r.ts));
|
||||
if (oldestTs < fromTs) break;
|
||||
if (cancelledRef.current) {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
// If we've paginated back past the fromTs boundary, there's nothing more
|
||||
// in range to fetch (use the raw paginated ts, not the collected one).
|
||||
if (fromTs !== null && oldestRawTs < fromTs) break;
|
||||
// Hard cap so "export all" can't run away and OOM the tab.
|
||||
if (pageCount >= MAX_EXPORT_PAGES) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
pageCount += 1;
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
canLoadMore = await mx.paginateEventTimeline(timeline, {
|
||||
backwards: true,
|
||||
limit: 100,
|
||||
limit: PAGE_LIMIT,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await addEvents(timeline.getEvents());
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
setNotice(`Export cancelled after ${collected.length} messages.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort chronologically (oldest first)
|
||||
collected.sort((a, b) => a.ts - b.ts);
|
||||
|
||||
@@ -191,6 +239,12 @@ ${msgRows}
|
||||
a.download = `export-${safeRoomName}-${dateStr}.${ext}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
if (truncated) {
|
||||
setNotice(
|
||||
`Export truncated to ${collected.length} messages (reached the ${MAX_EXPORT_PAGES}-page limit). Narrow the date range to export older history.`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
@@ -297,24 +351,35 @@ ${msgRows}
|
||||
? `Exporting… ${exportCount} messages`
|
||||
: 'Export will download automatically.'}
|
||||
</Text>
|
||||
<Button
|
||||
size="400"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
disabled={exporting}
|
||||
onClick={handleExport}
|
||||
before={
|
||||
exporting ? (
|
||||
<Spinner size="200" />
|
||||
) : (
|
||||
<Icon src={Icons.Download} size="100" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="B400">{exporting ? 'Exporting…' : 'Export'}</Text>
|
||||
</Button>
|
||||
{exporting ? (
|
||||
<Button
|
||||
size="400"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={handleCancel}
|
||||
before={<Icon src={Icons.Cross} size="100" />}
|
||||
>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="400"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={handleExport}
|
||||
before={<Icon src={Icons.Download} size="100" />}
|
||||
>
|
||||
<Text size="B400">Export</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{notice && (
|
||||
<Text size="T200" priority="400">
|
||||
{notice}
|
||||
</Text>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user