feat(dm): read-only guard blocks message create/edit/delete in a Deleted-User 1-on-1 (S3)
This commit is contained in:
@@ -3,7 +3,7 @@ import { eq, and, or, desc, lt, inArray, isNull, sql } from 'drizzle-orm';
|
|||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.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 { connectionManager } from '../ws/handler.js';
|
||||||
import {
|
import {
|
||||||
MAX_MESSAGE_LENGTH,
|
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 });
|
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 hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
||||||
const hasAttachments = attachmentIds && attachmentIds.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 });
|
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();
|
const now = Date.now();
|
||||||
db.update(schema.dmMessages)
|
db.update(schema.dmMessages)
|
||||||
.set({ content: content.trim(), editedAt: now })
|
.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 });
|
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
|
// Collect attachment filenames before deleting
|
||||||
const attachmentRows = db.select({ filename: schema.attachments.filename })
|
const attachmentRows = db.select({ filename: schema.attachments.filename })
|
||||||
.from(schema.attachments)
|
.from(schema.attachments)
|
||||||
|
|||||||
@@ -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 { getDb, schema } from '../db/index.js';
|
||||||
import {
|
import {
|
||||||
PermissionBits,
|
PermissionBits,
|
||||||
@@ -261,6 +261,28 @@ export function isDmMember(dmChannelId: string, userId: string): boolean {
|
|||||||
return member !== undefined;
|
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 {
|
export function isBanned(spaceId: string, userId: string): boolean {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const ban = db.select().from(schema.bans)
|
const ban = db.select().from(schema.bans)
|
||||||
|
|||||||
@@ -402,6 +402,55 @@ describe('Federation identity deletion — server suite', () => {
|
|||||||
remote.close();
|
remote.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('#19 read-only: mutations on a Deleted-User 1-on-1 are rejected 403 recipient_deleted', async () => {
|
||||||
|
const fx = await setupFullDeletionFixture('t19');
|
||||||
|
|
||||||
|
// Survivor authors a message BEFORE the deletion so there is a message THEY own
|
||||||
|
// to edit/delete — this ensures the read-only guard (not the ownership 403) is
|
||||||
|
// what's exercised on the PATCH/DELETE paths, which resolve via msg.dmChannelId.
|
||||||
|
const pre = await fetch(`${harness.remote.origin}/api/dm/${fx.dmChannelId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${fx.observerOnRemote.token}` },
|
||||||
|
body: JSON.stringify({ content: 'before deletion' }),
|
||||||
|
});
|
||||||
|
expect(pre.status).toBe(201);
|
||||||
|
const { id: messageId } = await pre.json() as { id: string };
|
||||||
|
|
||||||
|
// Tombstone the remote (federated) user via soft delete
|
||||||
|
const del = await fetch(`${harness.home.origin}/api/users/@me/federation-identity/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${fx.homeUser.token}` },
|
||||||
|
body: JSON.stringify({ origins: [harness.remote.origin], mode: 'soft' }),
|
||||||
|
});
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
|
||||||
|
// Survivor (observerOnRemote) POST -> 403 recipient_deleted
|
||||||
|
const post = await fetch(`${harness.remote.origin}/api/dm/${fx.dmChannelId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${fx.observerOnRemote.token}` },
|
||||||
|
body: JSON.stringify({ content: 'still there?' }),
|
||||||
|
});
|
||||||
|
expect(post.status).toBe(403);
|
||||||
|
expect((await post.json()).code).toBe('recipient_deleted');
|
||||||
|
|
||||||
|
// PATCH the survivor's own message (resolves channel via msg.dmChannelId) -> 403
|
||||||
|
const patch = await fetch(`${harness.remote.origin}/api/dm/messages/${messageId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${fx.observerOnRemote.token}` },
|
||||||
|
body: JSON.stringify({ content: 'edit' }),
|
||||||
|
});
|
||||||
|
expect(patch.status).toBe(403);
|
||||||
|
expect((await patch.json()).code).toBe('recipient_deleted');
|
||||||
|
|
||||||
|
// DELETE the survivor's own message (resolves channel via msg.dmChannelId) -> 403
|
||||||
|
const remove = await fetch(`${harness.remote.origin}/api/dm/messages/${messageId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${fx.observerOnRemote.token}` },
|
||||||
|
});
|
||||||
|
expect(remove.status).toBe(403);
|
||||||
|
expect((await remove.json()).code).toBe('recipient_deleted');
|
||||||
|
});
|
||||||
|
|
||||||
it('#7 owned-spaces 409: ownership prevents deletion, registry preserved', async () => {
|
it('#7 owned-spaces 409: ownership prevents deletion, registry preserved', async () => {
|
||||||
const { createFederatedUser } = await import('./helpers/testUsers.js');
|
const { createFederatedUser } = await import('./helpers/testUsers.js');
|
||||||
const { openInspector } = await import('./helpers/dbInspect.js');
|
const { openInspector } = await import('./helpers/dbInspect.js');
|
||||||
|
|||||||
Reference in New Issue
Block a user