feat(dm): read-only guard blocks message create/edit/delete in a Deleted-User 1-on-1 (S3)

This commit is contained in:
Jannis Braun
2026-07-02 15:47:18 +02:00
parent 12d8256f65
commit 3ebdd048bd
3 changed files with 85 additions and 2 deletions
+13 -1
View File
@@ -3,7 +3,7 @@ import { eq, and, or, desc, lt, inArray, isNull, sql } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isDmMember } from '../utils/permissions.js';
import { isDmMember, isDeadOneOnOne } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import {
MAX_MESSAGE_LENGTH,
@@ -2456,6 +2456,10 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
if (isDeadOneOnOne(id, request.userId)) {
return reply.code(403).send({ error: "This user's account was deleted", code: 'recipient_deleted', statusCode: 403 });
}
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
const hasAttachments = attachmentIds && attachmentIds.length > 0;
@@ -2548,6 +2552,10 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 });
}
if (isDeadOneOnOne(msg.dmChannelId, request.userId)) {
return reply.code(403).send({ error: "This user's account was deleted", code: 'recipient_deleted', statusCode: 403 });
}
const now = Date.now();
db.update(schema.dmMessages)
.set({ content: content.trim(), editedAt: now })
@@ -2600,6 +2608,10 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'You can only delete your own messages', statusCode: 403 });
}
if (isDeadOneOnOne(msg.dmChannelId, request.userId)) {
return reply.code(403).send({ error: "This user's account was deleted", code: 'recipient_deleted', statusCode: 403 });
}
// Collect attachment filenames before deleting
const attachmentRows = db.select({ filename: schema.attachments.filename })
.from(schema.attachments)
+23 -1
View File
@@ -1,4 +1,4 @@
import { eq, and } from 'drizzle-orm';
import { eq, and, sql } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import {
PermissionBits,
@@ -261,6 +261,28 @@ export function isDmMember(dmChannelId: string, userId: string): boolean {
return member !== undefined;
}
/**
* True when a DM is a 1-on-1 (ownerId NULL) whose only other participant(s)
* are tombstoned (isDeleted=1). Used to make a Deleted-User thread read-only:
* no message create/edit/delete, so we never enqueue doomed/mis-directed relays.
*/
export function isDeadOneOnOne(dmChannelId: string, requesterId: string): boolean {
const db = getDb();
const channel = db.select({ ownerId: schema.dmChannels.ownerId })
.from(schema.dmChannels).where(eq(schema.dmChannels.id, dmChannelId)).get();
if (!channel || channel.ownerId !== null) return false; // groups are never a dead 1-on-1
const others = db.select({ isDeleted: schema.users.isDeleted })
.from(schema.dmMembers)
.innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id))
.where(and(
eq(schema.dmMembers.dmChannelId, dmChannelId),
sql`${schema.dmMembers.userId} != ${requesterId}`,
))
.all();
if (others.length === 0) return false;
return others.every(o => o.isDeleted === 1);
}
export function isBanned(spaceId: string, userId: string): boolean {
const db = getDb();
const ban = db.select().from(schema.bans)