164 lines
5.1 KiB
TypeScript
164 lines
5.1 KiB
TypeScript
import { Direction, Filter, MatrixClient, MatrixError } from 'matrix-js-sdk';
|
|||
|
|
|
||
|
|
// matrix-js-sdk keeps its IMessagesResponse private to client.ts.
|
||
|
|
type MessagesResponse = Awaited<ReturnType<MatrixClient['createMessagesRequest']>>;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* [Gitea #169] "Delete all my messages in this room": self-service redaction of
|
||
|
|
* every non-state event you sent. Two phases — collect (server-side
|
||
|
|
* sender-filtered /messages pagination) and redact (sequential, honours 429).
|
||
|
|
* Progress is persisted so a reload can resume; the mxc media behind
|
||
|
|
* attachments is NOT purged by a redaction (that is the server admin's job).
|
||
|
|
*/
|
||
|
|
|
||
|
|
export type RedactJobPhase = 'collecting' | 'redacting' | 'done' | 'cancelled' | 'error';
|
||
|
|
|
||
|
|
export type RedactJobState = {
|
||
|
|
roomId: string;
|
||
|
|
phase: RedactJobPhase;
|
||
|
|
/** Event ids still to redact (persisted so a reload can resume). */
|
||
|
|
pending: string[];
|
||
|
|
found: number;
|
||
|
|
redacted: number;
|
||
|
|
skipped: number;
|
||
|
|
error?: string;
|
||
|
|
leaveAfter: boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
const STORAGE_PREFIX = 'lotus-redact-job-';
|
||
|
|
|
||
|
|
export const loadRedactJob = (roomId: string): RedactJobState | null => {
|
||
|
|
try {
|
||
|
|
const raw = localStorage.getItem(STORAGE_PREFIX + roomId);
|
||
|
|
if (!raw) return null;
|
||
|
|
const parsed = JSON.parse(raw) as RedactJobState;
|
||
|
|
return Array.isArray(parsed.pending) ? parsed : null;
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
export const saveRedactJob = (state: RedactJobState): void => {
|
||
|
|
try {
|
||
|
|
// Only a finished (or discarded) job is forgotten; a cancelled/failed one
|
||
|
|
// keeps its pending list so it can be resumed after a reload.
|
||
|
|
if (state.phase === 'done' || state.pending.length === 0) {
|
||
|
|
localStorage.removeItem(STORAGE_PREFIX + state.roomId);
|
||
|
|
} else {
|
||
|
|
localStorage.setItem(STORAGE_PREFIX + state.roomId, JSON.stringify(state));
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// storage unavailable — the job still runs, it just can't resume
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/** Event types that are "yours to delete"; state events never qualify. */
|
||
|
|
export const isRedactableOwnEvent = (ev: {
|
||
|
|
type: string;
|
||
|
|
state_key?: string;
|
||
|
|
unsigned?: { redacted_because?: unknown };
|
||
|
|
content?: Record<string, unknown>;
|
||
|
|
}): boolean => {
|
||
|
|
if (typeof ev.state_key === 'string') return false;
|
||
|
|
if (ev.unsigned?.redacted_because) return false;
|
||
|
|
if (ev.type === 'm.room.redaction') return false;
|
||
|
|
// Already-redacted events come back with empty content.
|
||
|
|
if (ev.content && Object.keys(ev.content).length === 0) return false;
|
||
|
|
return true;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const ownMessagesFilter = (userId: string): Filter => {
|
||
|
|
const filter = new Filter(userId);
|
||
|
|
filter.setDefinition({
|
||
|
|
room: {
|
||
|
|
timeline: { senders: [userId], limit: 100 },
|
||
|
|
},
|
||
|
|
});
|
||
|
|
return filter;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Walk the room history backwards with a server-side sender filter and return
|
||
|
|
* the ids of every event we can redact. Calls `onProgress(found)` per page.
|
||
|
|
*/
|
||
|
|
export async function collectOwnEventIds(
|
||
|
|
mx: MatrixClient,
|
||
|
|
roomId: string,
|
||
|
|
userId: string,
|
||
|
|
onProgress: (found: number) => void,
|
||
|
|
signal?: AbortSignal,
|
||
|
|
): Promise<string[]> {
|
||
|
|
const ids: string[] = [];
|
||
|
|
const filter = ownMessagesFilter(userId);
|
||
|
|
let from: string | null = null;
|
||
|
|
for (;;) {
|
||
|
|
if (signal?.aborted) throw new DOMException('cancelled', 'AbortError');
|
||
|
|
const token: string | null = from;
|
||
|
|
// eslint-disable-next-line no-await-in-loop
|
||
|
|
const res: MessagesResponse = await withRateLimit(() =>
|
||
|
|
mx.createMessagesRequest(roomId, token, 100, Direction.Backward, filter),
|
||
|
|
);
|
||
|
|
res.chunk.forEach((ev) => {
|
||
|
|
if (ev.sender === userId && ev.event_id && isRedactableOwnEvent(ev)) ids.push(ev.event_id);
|
||
|
|
});
|
||
|
|
onProgress(ids.length);
|
||
|
|
if (!res.end || res.end === from || res.chunk.length === 0) break;
|
||
|
|
from = res.end;
|
||
|
|
}
|
||
|
|
return ids;
|
||
|
|
}
|
||
|
|
|
||
|
|
const sleep = (ms: number) =>
|
||
|
|
new Promise<void>((r) => {
|
||
|
|
setTimeout(r, ms);
|
||
|
|
});
|
||
|
|
|
||
|
|
async function withRateLimit<T>(op: () => Promise<T>, attempts = 6): Promise<T> {
|
||
|
|
let n = 0;
|
||
|
|
for (;;) {
|
||
|
|
try {
|
||
|
|
// eslint-disable-next-line no-await-in-loop
|
||
|
|
return await op();
|
||
|
|
} catch (e) {
|
||
|
|
if (e instanceof MatrixError && e.httpStatus === 429 && n < attempts) {
|
||
|
|
n += 1;
|
||
|
|
// eslint-disable-next-line no-await-in-loop
|
||
|
|
await sleep(e.getRetryAfterMs() ?? Math.min(1000 * 2 ** n, 30_000));
|
||
|
|
} else {
|
||
|
|
throw e;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Redact `state.pending` one by one, mutating and persisting `state` as it
|
||
|
|
* goes so a reload resumes where it stopped. Missing / already-redacted
|
||
|
|
* events are skipped, not fatal.
|
||
|
|
*/
|
||
|
|
export async function redactPending(
|
||
|
|
mx: MatrixClient,
|
||
|
|
state: RedactJobState,
|
||
|
|
onProgress: (state: RedactJobState) => void,
|
||
|
|
signal?: AbortSignal,
|
||
|
|
): Promise<void> {
|
||
|
|
while (state.pending.length > 0) {
|
||
|
|
if (signal?.aborted) throw new DOMException('cancelled', 'AbortError');
|
||
|
|
const id = state.pending[0];
|
||
|
|
try {
|
||
|
|
// eslint-disable-next-line no-await-in-loop
|
||
|
|
await withRateLimit(() => mx.redactEvent(state.roomId, id));
|
||
|
|
state.redacted += 1;
|
||
|
|
} catch (e) {
|
||
|
|
if (e instanceof MatrixError && (e.errcode === 'M_NOT_FOUND' || e.httpStatus === 404)) {
|
||
|
|
state.skipped += 1;
|
||
|
|
} else {
|
||
|
|
throw e;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
state.pending.shift();
|
||
|
|
saveRedactJob(state);
|
||
|
|
onProgress(state);
|
||
|
|
}
|
||
|
|
}
|