From 8c5c91859e36b748c409617115983c21ada413c0 Mon Sep 17 00:00:00 2001 From: Sivert Date: Mon, 28 Sep 2026 16:42:32 +0200 Subject: [PATCH] Take the reporter's copy of an MLS message as a report (GRYT-1557) The server never has an MLS message, so chat:report now takes mls: { senderServerUserId, text } and stores it as an unverified report (decision 11). The reporter and the sender both have to be in the conversation, and it can't be your own message; otherwise it answers "Message not found", like a DM that doesn't exist. Permission, the already-reported check and the rate limit are the same as any report. reports:list marks those cards unverified, and server:info.mls says reports: true. The queue groups by conversation and message id, since an MLS id is the sender's choice and could copy a real one, and reports:resolve narrows to the conversation it's given. A delete only deletes and broadcasts when the server has the message. New column: reports.unverified, 0 on every existing row. Co-Authored-By: Claude Opus 5.5 --- src/db/interfaces.ts | 2 + src/db/sqlite/connection.ts | 5 + src/db/sqlite/reports.ts | 23 +- src/socket/handlers/mlsReports.test.ts | 313 +++++++++++++++++++++++++ src/socket/handlers/reports.ts | 93 +++++++- src/socket/utils/server.ts | 6 +- src/socket/utils/serverInfo.test.ts | 4 +- 7 files changed, 426 insertions(+), 20 deletions(-) create mode 100644 src/socket/handlers/mlsReports.test.ts diff --git a/src/db/interfaces.ts b/src/db/interfaces.ts index d2fb8839..aeb4515d 100644 --- a/src/db/interfaces.ts +++ b/src/db/interfaces.ts @@ -533,6 +533,8 @@ export interface ReportRecord { message_attachments: string[] | null; message_sender_server_id: string; message_sender_nickname: string | null; + /** The reporter's own copy of an MLS message, which the server never had (GRYT-1557). */ + unverified: boolean; status: "pending" | "approved" | "deleted"; resolved_by_server_user_id: string | null; created_at: Date; diff --git a/src/db/sqlite/connection.ts b/src/db/sqlite/connection.ts index 6a674565..ae1c251b 100644 --- a/src/db/sqlite/connection.ts +++ b/src/db/sqlite/connection.ts @@ -697,6 +697,11 @@ function runMigrations(d: DatabaseSync): void { d.exec("ALTER TABLE channels ADD COLUMN permission_scope_id TEXT"); } + // 0 on every existing row: each of those was copied from the server's own message. + if (!hasColumn(d, "reports", "unverified")) { + d.exec("ALTER TABLE reports ADD COLUMN unverified INTEGER NOT NULL DEFAULT 0"); + } + // NULL is the top level, so an upgrade leaves the sidebar exactly as it was. if (!hasColumn(d, "sidebar_items", "parent_item_id")) { d.exec("ALTER TABLE sidebar_items ADD COLUMN parent_item_id TEXT"); diff --git a/src/db/sqlite/reports.ts b/src/db/sqlite/reports.ts index de2f05fe..4b888185 100644 --- a/src/db/sqlite/reports.ts +++ b/src/db/sqlite/reports.ts @@ -9,6 +9,7 @@ function rowToReport(r: Record): ReportRecord { reporter_server_user_id: r.reporter_server_user_id as string, message_text: (r.message_text as string) ?? null, message_attachments: r.message_attachments ? JSON.parse(r.message_attachments as string) : null, message_sender_server_id: r.message_sender_server_id as string, message_sender_nickname: (r.message_sender_nickname as string) ?? null, + unverified: r.unverified === 1, status: (r.status as "pending" | "approved" | "deleted") ?? "pending", resolved_by_server_user_id: (r.resolved_by_server_user_id as string) ?? null, created_at: fromIso(r.created_at as string), resolved_at: fromIsoNullable(r.resolved_at as string | null), @@ -18,16 +19,17 @@ function rowToReport(r: Record): ReportRecord { export async function insertReport(record: { message_id: string; conversation_id: string; reporter_server_user_id: string; message_text: string | null; message_attachments: string[] | null; message_sender_server_id: string; message_sender_nickname: string | null; + unverified?: boolean; }): Promise { const db = getSqliteDb(); const report_id = randomUUID(); const created_at = new Date(); db.prepare( - `INSERT INTO reports (report_id, message_id, conversation_id, reporter_server_user_id, message_text, message_attachments, message_sender_server_id, message_sender_nickname, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)` + `INSERT INTO reports (report_id, message_id, conversation_id, reporter_server_user_id, message_text, message_attachments, message_sender_server_id, message_sender_nickname, unverified, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)` ).run(report_id, record.message_id, record.conversation_id, record.reporter_server_user_id, record.message_text, record.message_attachments ? JSON.stringify(record.message_attachments) : null, - record.message_sender_server_id, record.message_sender_nickname, toIso(created_at)); - return { report_id, created_at, status: "pending", resolved_by_server_user_id: null, resolved_at: null, ...record }; + record.message_sender_server_id, record.message_sender_nickname, record.unverified ? 1 : 0, toIso(created_at)); + return { report_id, created_at, status: "pending", resolved_by_server_user_id: null, resolved_at: null, ...record, unverified: !!record.unverified }; } export async function listReports(statusFilter?: string, limit = 100): Promise { @@ -44,9 +46,10 @@ export async function resolveReport(reportId: string, resolution: "approved" | " return result.changes > 0; } -export async function resolveAllReportsForMessage(messageId: string, resolution: "approved" | "deleted", resolvedByServerUserId: string): Promise { +/** `conversationId` narrows it: an MLS message's id is the sender's choice, so it can repeat one elsewhere. */ +export async function resolveAllReportsForMessage(messageId: string, resolution: "approved" | "deleted", resolvedByServerUserId: string, conversationId?: string): Promise { const pending = await listReports("pending"); - const matching = pending.filter((r) => r.message_id === messageId); + const matching = pending.filter((r) => r.message_id === messageId && (!conversationId || r.conversation_id === conversationId)); let resolved = 0; for (const r of matching) { if (await resolveReport(r.report_id, resolution, resolvedByServerUserId)) resolved++; } return resolved; @@ -77,15 +80,17 @@ export async function listPendingReportConversationsForFile(fileId: string): Pro export async function getAggregatedPendingReports(): Promise< Array<{ message_id: string; conversation_id: string; message_text: string | null; message_attachments: string[] | null; - message_sender_server_id: string; message_sender_nickname: string | null; report_count: number; reporters: string[]; first_reported_at: Date; report_ids: string[]; }> + message_sender_server_id: string; message_sender_nickname: string | null; unverified: boolean; report_count: number; reporters: string[]; first_reported_at: Date; report_ids: string[]; }> > { const pending = await listReports("pending"); const byMessage = new Map; first_reported_at: Date; report_ids: string[]; }>(); + message_sender_server_id: string; message_sender_nickname: string | null; unverified: boolean; reporters: Set; first_reported_at: Date; report_ids: string[]; }>(); for (const r of pending) { - const ex = byMessage.get(r.message_id); + // By conversation too: an MLS message's id is the sender's choice and could copy a real one. + const key = JSON.stringify([r.conversation_id, r.message_id]); + const ex = byMessage.get(key); if (ex) { ex.reporters.add(r.reporter_server_user_id); ex.report_ids.push(r.report_id); if (r.created_at < ex.first_reported_at) ex.first_reported_at = r.created_at; } - else { byMessage.set(r.message_id, { message_id: r.message_id, conversation_id: r.conversation_id, message_text: r.message_text, message_attachments: r.message_attachments, message_sender_server_id: r.message_sender_server_id, message_sender_nickname: r.message_sender_nickname, reporters: new Set([r.reporter_server_user_id]), first_reported_at: r.created_at, report_ids: [r.report_id] }); } + else { byMessage.set(key, { message_id: r.message_id, conversation_id: r.conversation_id, message_text: r.message_text, message_attachments: r.message_attachments, message_sender_server_id: r.message_sender_server_id, message_sender_nickname: r.message_sender_nickname, unverified: r.unverified, reporters: new Set([r.reporter_server_user_id]), first_reported_at: r.created_at, report_ids: [r.report_id] }); } } return [...byMessage.values()].map((v) => ({ ...v, report_count: v.reporters.size, reporters: [...v.reporters] })) .sort((a, b) => b.report_count - a.report_count || b.first_reported_at.getTime() - a.first_reported_at.getTime()); diff --git a/src/socket/handlers/mlsReports.test.ts b/src/socket/handlers/mlsReports.test.ts new file mode 100644 index 00000000..8cde9ec0 --- /dev/null +++ b/src/socket/handlers/mlsReports.test.ts @@ -0,0 +1,313 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; + +import type { Permission } from "../../constants/permissions"; +import { initSqlite } from "../../db/sqlite/connection"; +import { createRoleDefinition } from "../../db/sqlite/roleDefinitions"; +import { createServerConfigIfNotExists, setServerRole } from "../../db/sqlite/servers"; +import { upsertUser } from "../../db/sqlite/users"; +import { createGroupConversation, openDirectConversation } from "../../db/sqlite/conversations"; +import { insertMessage } from "../../db/sqlite/messages"; +import { listReports } from "../../db/sqlite/reports"; +import type { Clients } from "../../types"; +import { generateAccessToken } from "../../utils/jwt"; +import { resetRateLimits } from "../../utils/rateLimiter"; +import { registerReportHandlers } from "./reports"; +import type { EventHandlerMap, HandlerContext } from "./types"; + +/** Reporting an MLS message: the reporter's copy, and who may send one (GRYT-1557). */ + +const HOST = "mlsreports.test:5001"; + +let dir: string; + +interface Emitted { + event: string; + payload: unknown; +} + +interface Actor { + clientId: string; + serverUserId: string; + grytUserId: string; + accessToken: string; + emitted: Emitted[]; + handlers: EventHandlerMap; + received: (event: string) => unknown[]; + clear: () => void; +} + +/** `disconnect` is here because `evictUser` calls it, and a stub without it + throws into a catch that looks exactly like a permission check firing. */ +interface FakeSocket { + emit: (event: string, payload?: unknown) => boolean; + disconnect: () => void; + disconnected: boolean; +} +const sockets = new Map(); +const clientsInfo: Clients = {}; +const io = { + to() { + return { emit() {} }; + }, + emit() {}, + sockets: { sockets }, +}; + +let seq = 0; + +/** `rank` rises with the permission set: the moderators below have to outrank + their targets, and a tie is refused before any permission is read. */ +async function connect(nickname: string, permissions: Permission[], rank = 10): Promise { + seq += 1; + const clientId = `socket-${seq}`; + const roleId = `role-${seq}`; + const grytUserId = `account-mls-report-${seq}`; + + await createRoleDefinition(roleId, { name: `Role ${seq}`, rank, permissions }); + const user = await upsertUser(grytUserId, nickname); + await setServerRole(user.server_user_id, roleId); + + const emitted: Emitted[] = []; + const socket = { + id: clientId, + handshake: { headers: { host: HOST }, address: "127.0.0.1" }, + emit(event: string, payload?: unknown) { + emitted.push({ event, payload }); + return true; + }, + join() {}, + leave() {}, + to() { + return { emit() {} }; + }, + disconnect() {}, + }; + + const fake: FakeSocket = { + emit(event: string, payload?: unknown) { + emitted.push({ event, payload }); + return true; + }, + disconnect() { + fake.disconnected = true; + }, + disconnected: false, + }; + sockets.set(clientId, fake); + + clientsInfo[clientId] = { + serverUserId: user.server_user_id, + grytUserId, + nickname, + color: "#666666", + isMuted: false, + isDeafened: false, + streamID: "", + hasJoinedChannel: false, + voiceChannelId: "", + isAFK: false, + cameraEnabled: false, + cameraStreamID: "", + screenShareEnabled: false, + screenShareVideoStreamID: "", + screenShareAudioStreamID: "", + isServerMuted: false, + isServerDeafened: false, + } as Clients[string]; + + const ctx = { + io, + socket, + clientId, + serverId: "mls-reports-test", + clientsInfo, + sfuClient: null, + getClientIp: () => `10.1.0.${seq}`, + clientAddressIsOwn: () => true, + } as unknown as HandlerContext; + + return { + clientId, + serverUserId: user.server_user_id, + grytUserId, + accessToken: generateAccessToken({ + grytUserId, + serverUserId: user.server_user_id, + nickname, + serverHost: HOST, + tokenVersion: 0, + }), + emitted, + handlers: registerReportHandlers(ctx), + received: (event: string) => emitted.filter((e) => e.event === event).map((e) => e.payload), + clear: () => { + emitted.length = 0; + }, + }; +} + +const REPORTER: Permission[] = ["read_messages", "view_members", "report_messages"]; +const MOD: Permission[] = [...REPORTER, "view_reports", "manage_reports"]; + +interface Card { + messageId: string; + conversationId: string; + messageText: string | null; + senderServerUserId: string; + senderNickname: string | null; + reportCount: number; + unverified?: boolean; +} + +async function cards(mod: Actor): Promise { + mod.clear(); + await mod.handlers["reports:list"]({ accessToken: mod.accessToken }); + const payload = mod.received("reports:list").at(-1) as { reports?: Card[] } | undefined; + return payload?.reports ?? []; +} + +async function reportCopy(who: Actor, conversationId: string, messageId: string, mls: unknown): Promise { + // The limit has its own test below; here it would refuse the fifth case for the wrong reason. + resetRateLimits(); + who.clear(); + await who.handlers["chat:report"]({ accessToken: who.accessToken, conversationId, messageId, mls }); +} + +const errors = (a: Actor) => a.received("chat:error"); + +let alice: Actor; +let bob: Actor; +let carol: Actor; +let mod: Actor; +let dm: string; + +before(async () => { + dir = mkdtempSync(join(tmpdir(), "gryt-mls-reports-")); + process.env.DATA_DIR = dir; + await initSqlite(); + await createServerConfigIfNotExists(); + alice = await connect("Alice", REPORTER); + bob = await connect("Bob", REPORTER); + carol = await connect("Carol", REPORTER); + mod = await connect("Mod", MOD, 50); + dm = (await openDirectConversation(alice.serverUserId, bob.serverUserId)).conversation_id; +}); + +after(() => { + delete process.env.DATA_DIR; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("reporting an MLS message", () => { + it("queues the reporter's copy, marked unverified, under the sender", async () => { + await reportCopy(alice, dm, "mls-1", { senderServerUserId: bob.serverUserId, text: "something awful" }); + assert.deepEqual(alice.received("report:submitted"), [{ messageId: "mls-1" }]); + const card = (await cards(mod)).find((c) => c.messageId === "mls-1"); + assert.ok(card); + assert.equal(card.conversationId, dm); + assert.equal(card.messageText, "something awful"); + assert.equal(card.senderServerUserId, bob.serverUserId); + assert.equal(card.senderNickname, "Bob"); + assert.equal(card.unverified, true); + }); + + it("refuses a second report of the same message from the same person", async () => { + await reportCopy(alice, dm, "mls-1", { senderServerUserId: bob.serverUserId, text: "again" }); + assert.deepEqual(alice.received("report:already_reported"), [{ messageId: "mls-1" }]); + assert.equal((await listReports("pending")).filter((r) => r.message_id === "mls-1").length, 1); + }); + + it("refuses your own message", async () => { + await reportCopy(alice, dm, "mls-own", { senderServerUserId: alice.serverUserId, text: "mine" }); + assert.deepEqual(errors(alice), ["You cannot report your own message"]); + }); + + it("refuses somebody outside the DM, as either reporter or sender, the same way", async () => { + await reportCopy(carol, dm, "mls-2", { senderServerUserId: bob.serverUserId, text: "made up" }); + assert.deepEqual(errors(carol), ["Message not found"]); + await reportCopy(alice, dm, "mls-3", { senderServerUserId: carol.serverUserId, text: "made up" }); + assert.deepEqual(errors(alice), ["Message not found"]); + await reportCopy(alice, "general", "mls-4", { senderServerUserId: bob.serverUserId, text: "a channel" }); + assert.deepEqual(errors(alice), ["Message not found"]); + await reportCopy(alice, "dm_0000", "mls-5", { senderServerUserId: bob.serverUserId, text: "no such DM" }); + assert.deepEqual(errors(alice), ["Message not found"]); + assert.equal((await listReports("pending")).some((r) => ["mls-2", "mls-3", "mls-4", "mls-5"].includes(r.message_id)), false); + }); + + it("takes a member's report in a group DM", async () => { + const group = (await createGroupConversation(alice.serverUserId, [bob.serverUserId, carol.serverUserId])).conversation_id; + await reportCopy(carol, group, "mls-group", { senderServerUserId: bob.serverUserId, text: "in the group" }); + assert.deepEqual(carol.received("report:submitted"), [{ messageId: "mls-group" }]); + }); + + it("refuses a copy that isn't one", async () => { + for (const bad of [ + null, + "text", + { text: "x" }, + { senderServerUserId: "", text: "x" }, + { senderServerUserId: 5, text: "x" }, + { senderServerUserId: "x", text: 5 }, + { senderServerUserId: "x", text: "x".repeat(32_001) }, + ]) { + await reportCopy(bob, dm, "mls-bad", bad); + assert.deepEqual(errors(bob), ["Invalid report payload"], JSON.stringify(bad)?.slice(0, 60)); + } + await reportCopy(bob, dm, "i".repeat(65), { senderServerUserId: alice.serverUserId, text: "x" }); + assert.deepEqual(errors(bob), ["Invalid report payload"]); + }); + + it("needs report_messages like any report", async () => { + const nobody = await connect("Nobody", ["read_messages"]); + const theirs = (await openDirectConversation(nobody.serverUserId, bob.serverUserId)).conversation_id; + await reportCopy(nobody, theirs, "mls-6", { senderServerUserId: bob.serverUserId, text: "x" }); + assert.equal((errors(nobody)[0] as { error?: string }).error, "forbidden"); + }); + + it("keeps a copy whose id repeats a real message's apart from that message's reports", async () => { + const real = await insertMessage({ + conversation_id: "general", + sender_server_id: bob.serverUserId, + text: "a real message", + attachments: null, + reactions: null, + } as never); + await carol.handlers["chat:report"]({ accessToken: carol.accessToken, conversationId: "general", messageId: real.message_id }); + await reportCopy(alice, dm, real.message_id, { senderServerUserId: bob.serverUserId, text: "a fake copy" }); + const both = (await cards(mod)).filter((c) => c.messageId === real.message_id); + assert.equal(both.length, 2); + const channelCard = both.find((c) => c.conversationId === "general")!; + assert.equal(channelCard.messageText, "a real message"); + assert.equal(channelCard.reportCount, 1); + assert.equal("unverified" in channelCard, false); + + // Dismissing the copy leaves the real report, and deleting it deletes nothing on the server. + mod.clear(); + await mod.handlers["reports:resolve"]({ accessToken: mod.accessToken, messageId: real.message_id, conversationId: dm, action: "delete" }); + const left = (await cards(mod)).filter((c) => c.messageId === real.message_id); + assert.deepEqual(left.map((c) => c.conversationId), ["general"]); + }); + + it("is rate-limited like any other report", async () => { + resetRateLimits(); + const dave = await connect("Dave", REPORTER); + const theirs = (await openDirectConversation(dave.serverUserId, bob.serverUserId)).conversation_id; + const answers: string[] = []; + for (let i = 0; i < 8; i++) { + dave.clear(); + await dave.handlers["chat:report"]({ + accessToken: dave.accessToken, + conversationId: theirs, + messageId: `mls-flood-${i}`, + mls: { senderServerUserId: bob.serverUserId, text: "x" }, + }); + answers.push(dave.received("report:submitted").length ? "ok" : ((errors(dave)[0] as { error?: string })?.error ?? "?")); + } + assert.ok(answers.includes("ok")); + assert.equal(answers.at(-1), "rate_limited"); + }); +}); diff --git a/src/socket/handlers/reports.ts b/src/socket/handlers/reports.ts index 63a36472..71278d02 100644 --- a/src/socket/handlers/reports.ts +++ b/src/socket/handlers/reports.ts @@ -20,6 +20,8 @@ import { hasUserReportedUser, getAggregatedPendingUserReports, resolveUserReportsFor, + getConversation, + isConversationMember, } from "../../db"; import { checkRateLimit, RateLimitRule } from "../../utils/rateLimiter"; @@ -30,6 +32,21 @@ const RL_REPORT_ADMIN: RateLimitRule = { limit: 30, windowMs: 60_000, scorePerAc go and look without saying where. */ const REASON_MAX = 1000; +/** The MLS content format's own limits, in @gryt/core: an id and a message's text. */ +const MLS_ID_MAX = 64; +const MLS_TEXT_MAX = 32_000; + +type MlsCopy = { senderServerUserId: string; text: string }; + +/** Null for anything off. The text is kept as sent, so a moderator reads what the reporter's app showed. */ +function readMlsCopy(value: unknown): MlsCopy | null { + if (!value || typeof value !== "object") return null; + const { senderServerUserId, text } = value as Record; + if (typeof senderServerUserId !== "string" || !senderServerUserId) return null; + if (typeof text !== "string" || text.length > MLS_TEXT_MAX) return null; + return { senderServerUserId, text }; +} + export function registerReportHandlers(ctx: HandlerContext): EventHandlerMap { const { io, socket, clientId, serverId, clientsInfo, sfuClient } = ctx; @@ -39,11 +56,64 @@ export function registerReportHandlers(ctx: HandlerContext): EventHandlerMap { return checkRateLimit(event, userId, ip, rule); } + /** + * The server never had an MLS message, so it takes the reporter's copy and marks it unverified. + * Both have to be in the conversation, and it has to be somebody else's message. + */ + async function reportMlsCopy(reporterId: string, conversationId: string, messageId: string, raw: unknown): Promise { + const copy = readMlsCopy(raw); + if (!copy || typeof messageId !== "string" || messageId.length > MLS_ID_MAX) { + socket.emit("chat:error", "Invalid report payload"); + return; + } + const conversation = await getConversation(conversationId); + const inIt = + !!conversation && + (await isConversationMember(conversationId, reporterId)) && + (await isConversationMember(conversationId, copy.senderServerUserId)); + if (!inIt) { + // One answer for "no such DM" and "not yours", so this can't be used to probe either. + socket.emit("chat:error", "Message not found"); + return; + } + if (copy.senderServerUserId === reporterId) { + socket.emit("chat:error", "You cannot report your own message"); + return; + } + if (await hasUserReportedMessage(messageId, reporterId)) { + socket.emit("report:already_reported", { messageId }); + return; + } + + const senderUser = await getUserByServerId(copy.senderServerUserId); + await insertReport({ + message_id: messageId, + conversation_id: conversationId, + reporter_server_user_id: reporterId, + message_text: copy.text, + message_attachments: null, + message_sender_server_id: copy.senderServerUserId, + message_sender_nickname: senderUser?.nickname ?? null, + unverified: true, + }); + + socket.emit("report:submitted", { messageId }); + + insertServerAudit({ + actorServerUserId: reporterId, + action: "message_report", + target: messageId, + meta: { conversationId, unverified: true }, + }).catch((e) => consola.warn("audit log write failed", e)); + } + return { "chat:report": async (payload: { accessToken: string; conversationId: string; messageId: string; + /** The reporter's decrypted copy of an MLS message (decision 11, GRYT-1557). */ + mls?: unknown; }) => { try { const rl = rlCheck("chat:report", RL_REPORT); @@ -75,6 +145,11 @@ export function registerReportHandlers(ctx: HandlerContext): EventHandlerMap { return; } + if (payload.mls !== undefined) { + await reportMlsCopy(auth.tokenPayload.serverUserId, payload.conversationId, payload.messageId, payload.mls); + return; + } + const message = await getMessageById(payload.conversationId, payload.messageId); if (!message) { socket.emit("chat:error", "Message not found"); @@ -267,6 +342,8 @@ export function registerReportHandlers(ctx: HandlerContext): EventHandlerMap { }) ?? null, senderServerUserId: r.message_sender_server_id, senderNickname: r.message_sender_nickname, + // Only when true, so a card from before this reads the same as it did. + ...(r.unverified ? { unverified: true } : {}), reportCount: r.report_count, reporters: r.reporters, firstReportedAt: r.first_reported_at, @@ -310,6 +387,7 @@ export function registerReportHandlers(ctx: HandlerContext): EventHandlerMap { payload.messageId, "approved", auth.tokenPayload.serverUserId, + payload.conversationId || undefined, ); insertServerAudit({ @@ -327,13 +405,16 @@ export function registerReportHandlers(ctx: HandlerContext): EventHandlerMap { payload.messageId, "deleted", auth.tokenPayload.serverUserId, + payload.conversationId || undefined, ); - await deleteMessage(payload.conversationId, payload.messageId); - - io.emit("chat:deleted", { - conversation_id: payload.conversationId, - message_id: payload.messageId, - }); + // An MLS message was never here to delete, and announcing one would name the DM to everybody. + if (payload.conversationId && (await getMessageById(payload.conversationId, payload.messageId))) { + await deleteMessage(payload.conversationId, payload.messageId); + io.emit("chat:deleted", { + conversation_id: payload.conversationId, + message_id: payload.messageId, + }); + } insertServerAudit({ actorServerUserId: auth.tokenPayload.serverUserId, diff --git a/src/socket/utils/server.ts b/src/socket/utils/server.ts index 0ac5abea..bcbceecb 100644 --- a/src/socket/utils/server.ts +++ b/src/socket/utils/server.ts @@ -215,9 +215,9 @@ export async function sendInfo(socket: Socket, clientsInfo: Clients | undefined, /** A constant, not a setting: either the code relays the column or it does not. Absent means no, so no client needs a table of versions. */ encryptedDirectMessages: true, - /** The MLS delivery service (GRYT-1500). A client only uses MLS where this is present, - and `retentionDays` is how long a device can be away and still catch up. */ - mls: { version: 1, ciphersuites: [MLS_CIPHERSUITE_ID], retentionDays: mlsRetentionDays() }, + /** The MLS delivery service (GRYT-1500). `retentionDays` is how long a device can be away, + and `reports` means `chat:report` takes the reporter's copy of an MLS message (GRYT-1557). */ + mls: { version: 1, ciphersuites: [MLS_CIPHERSUITE_ID], retentionDays: mlsRetentionDays(), reports: true }, }; socket.emit("server:info", serverInfo); diff --git a/src/socket/utils/serverInfo.test.ts b/src/socket/utils/serverInfo.test.ts index 98d97757..7d49168d 100644 --- a/src/socket/utils/serverInfo.test.ts +++ b/src/socket/utils/serverInfo.test.ts @@ -92,11 +92,11 @@ describe("server:info version disclosure", () => { }); describe("server:info MLS capability", () => { - it("advertises the delivery service with its suite and retention", async () => { + it("advertises the delivery service with its suite, retention and reports", async () => { const emitted: Emitted[] = []; await sendInfo(fakeSocket(emitted), { [SOCKET_ID]: client({ serverUserId: "temp_1" }) }, "server-1"); - assert.deepEqual(emitted[0].payload.mls, { version: 1, ciphersuites: [1], retentionDays: 30 }, + assert.deepEqual(emitted[0].payload.mls, { version: 1, ciphersuites: [1], retentionDays: 30, reports: true }, "a client only uses MLS on a server that says so"); }); });