Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/db/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,16 @@ export interface MessageRecord {
text_fallback?: boolean;
/** What a webhook posted under. Enrichment turns it into sender_nickname. */
sender_display_name?: string | null;
/** Set on the notice the server writes for apps from before MLS, sent as "system".
Apps that read MLS hide these rows (GRYT-1508). */
mls_placeholder?: MlsPlaceholder;
}

export interface MlsPlaceholder {
/** The entry in the conversation's MLS group log this row stands in for. */
seq: number;
/** Who really sent it, so a block hides the notice too. */
sender_server_id: string;
}

export interface StoredWebhookCard {
Expand Down
6 changes: 6 additions & 0 deletions src/db/sqlite/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,12 @@ function runMigrations(d: DatabaseSync): void {
if (!hasColumn(d, "messages", "sender_avatar_file_id")) {
d.exec("ALTER TABLE messages ADD COLUMN sender_avatar_file_id TEXT");
}
// A placeholder the server writes for apps from before MLS, and whose MLS message
// it stands in for (GRYT-1508). NULL on every other row.
if (!hasColumn(d, "messages", "mls_seq")) {
d.exec("ALTER TABLE messages ADD COLUMN mls_seq INTEGER");
d.exec("ALTER TABLE messages ADD COLUMN mls_sender_server_id TEXT");
}
// One stored file per picture a webhook sends, however often it sends it.
d.exec(`CREATE TABLE IF NOT EXISTS webhook_media (
webhook_id TEXT NOT NULL,
Expand Down
10 changes: 6 additions & 4 deletions src/db/sqlite/contactPrefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,13 @@ export async function setContactPrefs(grytUserId: string, prefs: ContactPrefs):
).run(grytUserId, prefs.messages, prefs.calls, toIso(new Date()));
}

/** Whether `author` has sent anything in their one-to-one with `other`. The
sender column is stored for sealed messages too, so this works on those. */
/** Whether `author` has sent anything in their one-to-one with `other`. Sealed messages
keep their sender, and an MLS one counts through its placeholder. */
export async function hasWrittenTo(authorServerUserId: string, otherServerUserId: string): Promise<boolean> {
const row = getSqliteDb()
.prepare(`SELECT 1 FROM messages WHERE conversation_id = ? AND sender_server_id = ? LIMIT 1`)
.get(directConversationId(authorServerUserId, otherServerUserId), authorServerUserId);
.prepare(
`SELECT 1 FROM messages WHERE conversation_id = ? AND (sender_server_id = ? OR mls_sender_server_id = ?) LIMIT 1`,
)
.get(directConversationId(authorServerUserId, otherServerUserId), authorServerUserId, authorServerUserId);
return !!row;
}
1 change: 1 addition & 0 deletions src/db/sqlite/mergeGuest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface GuestMerge {
fails when a new column that can hold a server user id is not accounted for. */
export const AUTHOR_COLUMNS: ReadonlyArray<readonly [table: string, column: string]> = [
["messages", "sender_server_id"],
["messages", "mls_sender_server_id"],
["threads", "created_by"],
["conversations", "created_by_server_user_id"],
["files", "uploaded_by_server_user_id"],
Expand Down
7 changes: 6 additions & 1 deletion src/db/sqlite/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ function rowToMessage(r: Record<string, unknown>): MessageRecord {
...(r.text_fallback ? { text_fallback: true } : {}),
...(r.sender_display_name ? { sender_display_name: r.sender_display_name as string } : {}),
...(r.sender_avatar_file_id ? { sender_avatar_file_id: r.sender_avatar_file_id as string } : {}),
...(r.mls_seq != null
? { mls_placeholder: { seq: Number(r.mls_seq), sender_server_id: r.mls_sender_server_id as string } }
: {}),
};
}

Expand All @@ -32,7 +35,7 @@ export async function insertMessage(record: Omit<MessageRecord, "message_id" | "
const message_id = record.message_id ?? randomUUID();

db.prepare(
`INSERT INTO messages (conversation_id, message_id, sender_server_id, text, sealed, attachments, reactions, reply_to_message_id, thread_id, cards, text_fallback, sender_display_name, sender_avatar_file_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO messages (conversation_id, message_id, sender_server_id, text, sealed, attachments, reactions, reply_to_message_id, thread_id, cards, text_fallback, sender_display_name, sender_avatar_file_id, mls_seq, mls_sender_server_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
record.conversation_id,
message_id,
Expand All @@ -47,6 +50,8 @@ export async function insertMessage(record: Omit<MessageRecord, "message_id" | "
record.text_fallback ? 1 : 0,
record.sender_display_name ?? null,
record.sender_avatar_file_id ?? null,
record.mls_placeholder?.seq ?? null,
record.mls_placeholder?.sender_server_id ?? null,
toIso(created_at),
);

Expand Down
2 changes: 1 addition & 1 deletion src/socket/handlers/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,7 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap {
const hidden = await blockedServerIdsFor(clientsInfo[clientId]?.serverUserId ?? "");
const visible = hidden.size === 0
? items
: items.filter((m) => !hidden.has(m.sender_server_id));
: items.filter((m) => !hidden.has(m.sender_server_id) && !hidden.has(m.mls_placeholder?.sender_server_id ?? ""));

let enrichedItems = await enrichMessages(visible);
enrichedItems = await enrichAttachments(enrichedItems);
Expand Down
34 changes: 33 additions & 1 deletion src/socket/handlers/mls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ import { setContactPrefs } from "../../db/sqlite/contactPrefs";
import { createGroupConversation, openDirectConversation } from "../../db/sqlite/conversations";
import { getMlsGroupForConversation } from "../../db/sqlite/mls";
import { createServerConfigIfNotExists, setServerRole, updateServerConfig } from "../../db/sqlite/servers";
import { hasWrittenTo } from "../../db/sqlite/contactPrefs";
import { listMessages } from "../../db/sqlite/messages";
import { setUserModerationState, upsertUser } from "../../db/sqlite/users";
import { mlsRetentionDays, runMlsRetention } from "../../jobs/mlsRetention";
import { parseGroupMessage, parseKeyPackage, parseWelcome } from "../../services/mlsWire";
import type { Clients } from "../../types";
import { generateAccessToken } from "../../utils/jwt";
import { resetChannelIdCache } from "../utils/conversationAccess";
import { refreshClientPermissions } from "../utils/standing";
import { registerChatHandlers } from "./chat";
import { registerMlsHandlers } from "./mls";
import type { EventHandlerMap, HandlerContext } from "./types";

Expand Down Expand Up @@ -101,7 +104,7 @@ async function connect(nickname: string): Promise<Participant> {
getClientIp: () => `10.9.0.${seq}`,
clientAddressIsOwn: () => true,
} as unknown as HandlerContext;
const handlers = registerMlsHandlers(ctx);
const handlers = { ...registerChatHandlers(ctx), ...registerMlsHandlers(ctx) };
const accessToken = generateAccessToken({ grytUserId, serverUserId: user.server_user_id, nickname, serverHost: HOST, tokenVersion: 0 });

return {
Expand Down Expand Up @@ -422,6 +425,18 @@ describe("a DM over MLS", () => {
assert.equal(bob.received("dm:opened").length, 1, "the first message puts the conversation in Bob's list");
});

it("leaves apps from before MLS a line saying there's a message, marked so newer ones hide it", async () => {
const [line] = bob.received("chat:new");
assert.equal(line.sender_server_id, "system", "a system line, since a plain one would carry the old app's Not encrypted mark");
assert.equal(line.text, `[@Alice](mention:${alice.serverUserId}) sent an end-to-end encrypted message. Update Gryt to read it.`);
assert.deepEqual(line.mls_placeholder, { seq: 2, sender_server_id: alice.serverUserId });
assert.deepEqual(alice.received("chat:new"), [], "not to the sender, whose own old apps find it in history");

const stored = (await listMessages(dm)).filter((m) => m.mls_placeholder);
assert.deepEqual(stored.map((m) => m.mls_placeholder?.seq), [2]);
assert.equal(await hasWrittenTo(alice.serverUserId, bob.serverUserId), true, "so contact settings see the MLS message");
});

it("tells the other side about a new device, and lists devices by conversation", async () => {
alice.clear();
const b2 = await makeDevice("bob-laptop", 1);
Expand Down Expand Up @@ -520,6 +535,10 @@ describe("a DM over MLS", () => {
a1.state = c.newState;

assert.deepEqual(bob.received("mls:message").map((m) => m.kind), ["commit"]);
assert.deepEqual(bob.received("chat:new"), [], "nor the line for older apps");
await bob.handlers["chat:fetch"]({ conversationId: dm, limit: 50 });
const history = bob.received("chat:history")[0] as { items: { mls_placeholder?: { sender_server_id: string } }[] };
assert.ok(!history.items.some((m) => m.mls_placeholder), "and history leaves it out too");
const fetched = await bob.call("mls:log:fetch", { conversationId: dm, after: head });
assert.deepEqual((fetched.entries as { kind: string }[]).map((e) => e.kind), ["commit"]);
assert.equal(fetched.nextCursor, head + 2, "the cursor still moves past what was held back");
Expand All @@ -538,6 +557,19 @@ describe("a DM over MLS", () => {
const r = await alice.call("mls:group:create", { conversationId: group.conversation_id, groupId: "cd" });
assert.equal(r.error, "not_supported");
});

it("leaves no line for what isn't a message", async () => {
bob.clear();
const before = (await listMessages(dm)).length;
const quiet = await alice.call("mls:send", { conversationId: dm, deviceId: a1.id, message: await applicationMessage(a1, "a reaction"), placeholder: false });
assert.equal(quiet.ok, true);
assert.equal(bob.received("mls:message").length, 1);
assert.deepEqual(bob.received("chat:new"), []);
assert.equal((await listMessages(dm)).length, before);

const odd = await alice.call("mls:send", { conversationId: dm, deviceId: a1.id, message: await applicationMessage(a1, "x"), placeholder: "no" });
assert.equal(odd.error, "invalid_payload");
});
});

describe("retention", () => {
Expand Down
35 changes: 34 additions & 1 deletion src/socket/handlers/mls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getMlsGroupForConversation,
getServerConfig,
getUserByServerId,
insertMessage,
isMlsDevice,
listConversationsForUser,
listMlsDevices,
Expand All @@ -37,6 +38,8 @@ import { textMuteError, textMuteFor } from "../../moderation/textMute";
import { mayInChannel } from "../../services/channelPermissions";
import { asBytes, parseGroupMessage, parseKeyPackage, parseWelcome } from "../../services/mlsWire";
import { SEALED_MAX_LENGTH } from "../../utils/messageLimits";
import { appendCachedMessage } from "../utils/messageCache";
import { formatMlsPlaceholder, SYSTEM_SENDER_ID } from "../utils/systemMessages";
import { checkRateLimit, type RateLimitRule } from "../../utils/rateLimiter";
import { requireAuth, type AuthResult } from "../middleware/auth";
import { CONTACT_REFUSALS, mayMessage, peerOf } from "../utils/contactGate";
Expand Down Expand Up @@ -239,6 +242,26 @@ export function registerMlsHandlers(ctx: HandlerContext): EventHandlerMap {
emitTo(recipients, "mls:message", entryView(conversationId, entry));
}

/* The line apps from before MLS show instead (GRYT-1508). Not to the sender's own
sockets: their old apps find it in history, and aren't notified about themselves. */
async function writePlaceholder(self: string, memberIds: string[], conversationId: string, seq: number, createdAt: string): Promise<void> {
const sender = await getUserByServerId(self);
if (!sender) return;
const msg = await insertMessage({
conversation_id: conversationId,
sender_server_id: SYSTEM_SENDER_ID,
text: formatMlsPlaceholder(sender.nickname, sender.server_user_id),
attachments: null,
reactions: null,
mls_placeholder: { seq, sender_server_id: sender.server_user_id },
created_at: new Date(createdAt),
});
appendCachedMessage(conversationId, msg);
const blockers = await blockersOfSender(sender.server_user_id);
const audience = memberIds.filter((id) => id !== sender.server_user_id && !blockers.has(id));
emitTo(socketsOf(audience), "chat:new", { ...msg, sender_nickname: "System", sender_avatar_file_id: undefined });
}

/* Whoever shares a DM with them adds the new device the next time they send. */
async function announceDevices(serverUserId: string): Promise<void> {
const people = new Set([serverUserId]);
Expand Down Expand Up @@ -529,13 +552,17 @@ export function registerMlsHandlers(ctx: HandlerContext): EventHandlerMap {

/** An application message (PrivateMessage) or a proposal (PublicMessage). */
"mls:send": async (
payload: { accessToken: string; conversationId: string; deviceId: string; message: unknown },
payload: { accessToken: string; conversationId: string; deviceId: string; message: unknown; placeholder?: unknown },
ack: Ack,
) => {
ack = typeof ack === "function" ? ack : () => {};
try {
const auth = await begin("mls:send", RL_SEND, payload, ack);
if (!auth || !ownDevice(auth, payload.deviceId, ack)) return;
if (payload.placeholder !== undefined && typeof payload.placeholder !== "boolean") {
ack(fail("invalid_payload", "placeholder has to be true or false."));
return;
}
const self = auth.tokenPayload.serverUserId;
const memberIds = await dmOf(payload.conversationId, self, ack);
if (!memberIds) return;
Expand Down Expand Up @@ -614,6 +641,12 @@ export function registerMlsHandlers(ctx: HandlerContext): EventHandlerMap {
groupId: group.groupId, seq: result.seq, kind: parsed.kind, epoch: parsed.epoch,
senderServerUserId: self, senderDeviceId: payload.deviceId, data: bytes, createdAt: result.createdAt,
}, payload.conversationId);
// A reaction, edit or delete says false, or old apps show a line for each one.
if (application && payload.placeholder !== false) {
await writePlaceholder(self, memberIds, payload.conversationId, result.seq, result.createdAt).catch((err) =>
consola.warn("mls placeholder failed", payload.conversationId, err),
);
}
ack({ ok: true, seq: result.seq });
} catch (err) {
consola.error("mls:send failed", err);
Expand Down
8 changes: 7 additions & 1 deletion src/socket/utils/systemMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getServerConfig, insertMessage, listServerChannels } from "../../db";
import type { Clients } from "../../types";
import { recipientClientIds } from "./recipients";

const SYSTEM_SENDER_ID = "system";
export const SYSTEM_SENDER_ID = "system";

let cachedChannelId: string | null = null;
let channelCacheFetchedAt = 0;
Expand Down Expand Up @@ -77,6 +77,12 @@ export function formatJoinMessage(nickname: string, serverUserId: string): strin
return `[@${nickname}](mention:${serverUserId}) joined the server`;
}

/** What an app from before MLS shows for an MLS message (GRYT-1508). Plain text there
would carry that app's "Not encrypted" mark, so it goes out as a system line. */
export function formatMlsPlaceholder(nickname: string, serverUserId: string): string {
return `[@${nickname}](mention:${serverUserId}) sent an end-to-end encrypted message. Update Gryt to read it.`;
}

export function formatLeaveMessage(nickname: string, serverUserId: string): string {
return `[@${nickname}](mention:${serverUserId}) left the server`;
}
Loading