CI / Build & Quality Checks (pull_request) Successful in 2m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m33s
m.room.retention can be set to anything by anyone with state power. A fat-fingered `max_lifetime: 1000` blanked the timeline for every Lotus user and, for members with "Enforce Message Retention" on, the sweeper redacted all their messages within a minute. The RETENTION_MIN_MS floor existed but was never applied. - effectiveRetentionMs(): anything under 10 min counts as off; used by getRoomRetentionMs (sweeper) and the timeline's hiding. - The sweeper never deletes under a policy less than 24 h old, so a mistake or a malicious change can be reverted before anything is lost. (Local hiding of expired messages still applies at once.) Verified headless (the issue's repro, enforcement on): after max_lifetime 1000, all 4 messages stay visible and none are redacted after 45 s (before: timeline blank, all 4 redacted). Unit tests for the floor and the grace window. Fixes #298 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
58 lines
2.5 KiB
TypeScript
58 lines
2.5 KiB
TypeScript
import { Room } from 'matrix-js-sdk';
|
|
import { StateEvent } from '../../types/matrix/room';
|
|
|
|
// MSC1763 — per-room message retention (`m.room.retention`). `max_lifetime` is a
|
|
// duration in milliseconds after which a message is considered expired.
|
|
export type RetentionContent = {
|
|
max_lifetime?: number;
|
|
};
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
// Floor to avoid foot-guns (an admin fat-fingering a tiny value nuking a room).
|
|
export const RETENTION_MIN_MS = 10 * 60 * 1000;
|
|
|
|
export type RetentionPreset = { label: string; ms: number };
|
|
export const RETENTION_PRESETS: RetentionPreset[] = [
|
|
{ label: 'Off', ms: 0 },
|
|
{ label: '1 Day', ms: DAY_MS },
|
|
{ label: '1 Week', ms: 7 * DAY_MS },
|
|
{ label: '1 Month', ms: 30 * DAY_MS },
|
|
];
|
|
|
|
/**
|
|
* [Gitea #298] A usable retention window, or `undefined`. Anything under
|
|
* RETENTION_MIN_MS is treated as off: a fat-fingered `max_lifetime: 1000` (set
|
|
* from any client by anyone with state power) used to blank every timeline and
|
|
* get opted-in users' messages redacted within a minute.
|
|
*/
|
|
export const effectiveRetentionMs = (maxLifetime: unknown): number | undefined =>
|
|
typeof maxLifetime === 'number' && Number.isFinite(maxLifetime) && maxLifetime >= RETENTION_MIN_MS
|
|
? maxLifetime
|
|
: undefined;
|
|
|
|
/** The room's active retention window in ms, or `undefined` when unset/disabled. */
|
|
export const getRoomRetentionMs = (room: Room): number | undefined => {
|
|
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
|
|
return effectiveRetentionMs(event?.getContent<RetentionContent>()?.max_lifetime);
|
|
};
|
|
|
|
/**
|
|
* [Gitea #298] Deletion grace: the auto-delete sweeper ignores a retention
|
|
* policy for this long after it was set, so a mistaken or malicious change can
|
|
* be reverted before anything is permanently redacted. (Hiding expired
|
|
* messages locally is not destructive and applies at once.)
|
|
*/
|
|
export const RETENTION_DELETE_GRACE_MS = DAY_MS;
|
|
|
|
export const retentionDeletesAllowed = (policySetAtMs: number, nowMs: number): boolean =>
|
|
nowMs - policySetAtMs >= RETENTION_DELETE_GRACE_MS;
|
|
|
|
/** When the room's current retention policy was set (its state event's ts). */
|
|
export const getRoomRetentionSetAt = (room: Room): number | undefined =>
|
|
room.currentState.getStateEvents(StateEvent.RoomRetention, '')?.getTs();
|
|
|
|
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
|
|
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
|
|
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
|