feat: orphaned data cleanup, file deletion on message/account removal, and group DM improvements
- Add fileCleanup utility to delete uploaded files from disk on message/account deletion - Add migration to clean orphaned DM channels, attachments, reactions, read states, and stale moderator refs - Add FK constraints on dm_message_id in attachments and dm_reactions schema - Make bans.bannedBy and voiceRestrictions.moderatorId nullable for deleted moderators - Transfer group DM ownership on member leave or account deletion - Fully clean up orphaned DM channels (zero members) including files - Add "Leave Group" context menu for group DMs in ChannelSidebar - Add leaveDm action to spaceStore
This commit is contained in:
@@ -201,6 +201,9 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
// ─── Free usernames from already-tombstoned users ───────────────────────────
|
// ─── Free usernames from already-tombstoned users ───────────────────────────
|
||||||
migrateDeletedUsernames(db);
|
migrateDeletedUsernames(db);
|
||||||
|
|
||||||
|
// ─── Clean up orphaned data from deleted users and channels ────────────────
|
||||||
|
migrateOrphanedData(db);
|
||||||
|
|
||||||
console.log('Migrations complete.');
|
console.log('Migrations complete.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +445,101 @@ function migrateDeletedUsernames(db: Database.Database): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up orphaned data left behind by user deletions and channel removals:
|
||||||
|
* 1. DM channels with zero members
|
||||||
|
* 2. DM attachments/reactions referencing non-existent dm_messages
|
||||||
|
* 3. Read states referencing non-existent channels
|
||||||
|
* 4. Stale moderator references (bans.banned_by, voice_restrictions.moderator_id, join_requests.decided_by)
|
||||||
|
*/
|
||||||
|
function migrateOrphanedData(db: Database.Database): void {
|
||||||
|
// 1. Delete DM channels with zero members (cascade cleans dm_messages)
|
||||||
|
const orphanedDms = db.prepare(`
|
||||||
|
SELECT dc.id FROM dm_channels dc
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM dm_members dm WHERE dm.dm_channel_id = dc.id)
|
||||||
|
`).all() as { id: string }[];
|
||||||
|
|
||||||
|
if (orphanedDms.length > 0) {
|
||||||
|
const deleteAttachments = db.prepare(
|
||||||
|
'DELETE FROM attachments WHERE dm_message_id IN (SELECT id FROM dm_messages WHERE dm_channel_id = ?)'
|
||||||
|
);
|
||||||
|
const deleteReactions = db.prepare(
|
||||||
|
'DELETE FROM dm_reactions WHERE dm_message_id IN (SELECT id FROM dm_messages WHERE dm_channel_id = ?)'
|
||||||
|
);
|
||||||
|
const deleteDmChannel = db.prepare('DELETE FROM dm_channels WHERE id = ?');
|
||||||
|
|
||||||
|
for (const { id } of orphanedDms) {
|
||||||
|
deleteAttachments.run(id);
|
||||||
|
deleteReactions.run(id);
|
||||||
|
deleteDmChannel.run(id);
|
||||||
|
}
|
||||||
|
console.log(`Migrating: Cleaned up ${orphanedDms.length} orphaned DM channels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete orphaned DM attachments referencing non-existent dm_messages
|
||||||
|
const orphanedAtts = db.prepare(`
|
||||||
|
DELETE FROM attachments
|
||||||
|
WHERE dm_message_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM dm_messages WHERE dm_messages.id = attachments.dm_message_id)
|
||||||
|
`).run();
|
||||||
|
if (orphanedAtts.changes > 0) {
|
||||||
|
console.log(`Migrating: Cleaned up ${orphanedAtts.changes} orphaned DM attachments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Delete orphaned DM reactions referencing non-existent dm_messages
|
||||||
|
const orphanedReactions = db.prepare(`
|
||||||
|
DELETE FROM dm_reactions
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM dm_messages WHERE dm_messages.id = dm_reactions.dm_message_id)
|
||||||
|
`).run();
|
||||||
|
if (orphanedReactions.changes > 0) {
|
||||||
|
console.log(`Migrating: Cleaned up ${orphanedReactions.changes} orphaned DM reactions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Delete orphaned read_states referencing non-existent channels (or DM channels)
|
||||||
|
const orphanedReadStates = db.prepare(`
|
||||||
|
DELETE FROM read_states
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM channels WHERE channels.id = read_states.channel_id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM dm_channels WHERE dm_channels.id = read_states.channel_id)
|
||||||
|
`).run();
|
||||||
|
if (orphanedReadStates.changes > 0) {
|
||||||
|
console.log(`Migrating: Cleaned up ${orphanedReadStates.changes} orphaned read_states`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Nullify stale moderator references pointing to deleted users
|
||||||
|
try {
|
||||||
|
const staleBans = db.prepare(`
|
||||||
|
UPDATE bans SET banned_by = NULL
|
||||||
|
WHERE banned_by IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM users WHERE users.id = bans.banned_by AND users.is_deleted = 0)
|
||||||
|
`).run();
|
||||||
|
if (staleBans.changes > 0) {
|
||||||
|
console.log(`Migrating: Nullified ${staleBans.changes} stale bans.banned_by references`);
|
||||||
|
}
|
||||||
|
} catch { /* bans table may not exist yet */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const staleVoice = db.prepare(`
|
||||||
|
UPDATE voice_restrictions SET moderator_id = NULL
|
||||||
|
WHERE moderator_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM users WHERE users.id = voice_restrictions.moderator_id AND users.is_deleted = 0)
|
||||||
|
`).run();
|
||||||
|
if (staleVoice.changes > 0) {
|
||||||
|
console.log(`Migrating: Nullified ${staleVoice.changes} stale voice_restrictions.moderator_id references`);
|
||||||
|
}
|
||||||
|
} catch { /* voice_restrictions table may not exist yet */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const staleJoinReqs = db.prepare(`
|
||||||
|
UPDATE join_requests SET decided_by = NULL
|
||||||
|
WHERE decided_by IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM users WHERE users.id = join_requests.decided_by AND users.is_deleted = 0)
|
||||||
|
`).run();
|
||||||
|
if (staleJoinReqs.changes > 0) {
|
||||||
|
console.log(`Migrating: Nullified ${staleJoinReqs.changes} stale join_requests.decided_by references`);
|
||||||
|
}
|
||||||
|
} catch { /* join_requests table may not exist yet */ }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename non-namespaced replicated users: e.g. "test" → "test@nova.ddns.net"
|
* Rename non-namespaced replicated users: e.g. "test" → "test@nova.ddns.net"
|
||||||
* Frees plain usernames for native user creation and makes all federated users
|
* Frees plain usernames for native user creation and makes all federated users
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export const messages = sqliteTable('messages', {
|
|||||||
export const attachments = sqliteTable('attachments', {
|
export const attachments = sqliteTable('attachments', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
messageId: text('message_id').references(() => messages.id, { onDelete: 'cascade' }),
|
messageId: text('message_id').references(() => messages.id, { onDelete: 'cascade' }),
|
||||||
dmMessageId: text('dm_message_id'),
|
dmMessageId: text('dm_message_id').references(() => dmMessages.id, { onDelete: 'cascade' }),
|
||||||
filename: text('filename').notNull(),
|
filename: text('filename').notNull(),
|
||||||
originalName: text('original_name').notNull(),
|
originalName: text('original_name').notNull(),
|
||||||
mimetype: text('mimetype').notNull(),
|
mimetype: text('mimetype').notNull(),
|
||||||
@@ -127,7 +127,7 @@ export const reactions = sqliteTable('reactions', {
|
|||||||
|
|
||||||
export const dmReactions = sqliteTable('dm_reactions', {
|
export const dmReactions = sqliteTable('dm_reactions', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
dmMessageId: text('dm_message_id').notNull(),
|
dmMessageId: text('dm_message_id').notNull().references(() => dmMessages.id, { onDelete: 'cascade' }),
|
||||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
emoji: text('emoji').notNull(),
|
emoji: text('emoji').notNull(),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
@@ -206,7 +206,7 @@ export const bans = sqliteTable('bans', {
|
|||||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
reason: text('reason'),
|
reason: text('reason'),
|
||||||
bannedBy: text('banned_by').notNull().references(() => users.id),
|
bannedBy: text('banned_by').references(() => users.id),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
pk: primaryKey({ columns: [table.spaceId, table.userId] }),
|
pk: primaryKey({ columns: [table.spaceId, table.userId] }),
|
||||||
@@ -227,7 +227,7 @@ export const voiceRestrictions = sqliteTable('voice_restrictions', {
|
|||||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
restrictionType: text('restriction_type').notNull(), // 'mute' | 'deafen'
|
restrictionType: text('restriction_type').notNull(), // 'mute' | 'deafen'
|
||||||
moderatorId: text('moderator_id').notNull().references(() => users.id),
|
moderatorId: text('moderator_id').references(() => users.id),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
pk: primaryKey({ columns: [table.spaceId, table.userId, table.restrictionType] }),
|
pk: primaryKey({ columns: [table.spaceId, table.userId, table.restrictionType] }),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
Reaction,
|
Reaction,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
|
import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch-fetch reactions for a set of DM message IDs.
|
* Batch-fetch reactions for a set of DM message IDs.
|
||||||
@@ -638,6 +639,9 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
connectionManager.clearVoiceUserStatus(request.userId);
|
connectionManager.clearVoiceUserStatus(request.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check DM channel ownership before leaving
|
||||||
|
const dmChannel = db.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, id)).get();
|
||||||
|
|
||||||
// Delete dm_members row
|
// Delete dm_members row
|
||||||
db.delete(schema.dmMembers)
|
db.delete(schema.dmMembers)
|
||||||
.where(and(
|
.where(and(
|
||||||
@@ -646,12 +650,23 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
))
|
))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
// Broadcast dm_member_removed to remaining members
|
// Check remaining members
|
||||||
const remainingMembers = db.select()
|
const remainingMembers = db.select()
|
||||||
.from(schema.dmMembers)
|
.from(schema.dmMembers)
|
||||||
.where(eq(schema.dmMembers.dmChannelId, id))
|
.where(eq(schema.dmMembers.dmChannelId, id))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
|
if (remainingMembers.length > 0) {
|
||||||
|
// Transfer ownership if the leaving user was the owner
|
||||||
|
const nextOwner = remainingMembers[0];
|
||||||
|
if (dmChannel && dmChannel.ownerId === request.userId && nextOwner) {
|
||||||
|
db.update(schema.dmChannels)
|
||||||
|
.set({ ownerId: nextOwner.userId })
|
||||||
|
.where(eq(schema.dmChannels.id, id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast dm_member_removed to remaining members
|
||||||
for (const member of remainingMembers) {
|
for (const member of remainingMembers) {
|
||||||
connectionManager.sendToUser(member.userId, {
|
connectionManager.sendToUser(member.userId, {
|
||||||
type: 'dm_member_removed',
|
type: 'dm_member_removed',
|
||||||
@@ -659,6 +674,36 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Last member left — clean up the entire DM channel
|
||||||
|
// Collect attachment filenames for disk cleanup
|
||||||
|
const msgIds = db.select({ id: schema.dmMessages.id })
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(eq(schema.dmMessages.dmChannelId, id))
|
||||||
|
.all()
|
||||||
|
.map(m => m.id);
|
||||||
|
|
||||||
|
const filesToDelete: { filename: string }[] = [];
|
||||||
|
if (msgIds.length > 0) {
|
||||||
|
const attachmentRows = db.select({ filename: schema.attachments.filename })
|
||||||
|
.from(schema.attachments)
|
||||||
|
.where(inArray(schema.attachments.dmMessageId, msgIds))
|
||||||
|
.all();
|
||||||
|
filesToDelete.push(...attachmentRows);
|
||||||
|
|
||||||
|
// Delete attachments and reactions before cascade
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.delete(schema.attachments).where(inArray(schema.attachments.dmMessageId, msgIds)).run();
|
||||||
|
tx.delete(schema.dmReactions).where(inArray(schema.dmReactions.dmMessageId, msgIds)).run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the DM channel (cascades to dm_messages)
|
||||||
|
db.delete(schema.dmChannels).where(eq(schema.dmChannels.id, id)).run();
|
||||||
|
|
||||||
|
// Clean up files from disk
|
||||||
|
deleteAttachmentFiles(filesToDelete);
|
||||||
|
}
|
||||||
|
|
||||||
// Send dm_channel_closed to the leaving user
|
// Send dm_channel_closed to the leaving user
|
||||||
connectionManager.sendToUser(request.userId, {
|
connectionManager.sendToUser(request.userId, {
|
||||||
@@ -903,6 +948,12 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect attachment filenames before deleting
|
||||||
|
const attachmentRows = db.select({ filename: schema.attachments.filename })
|
||||||
|
.from(schema.attachments)
|
||||||
|
.where(eq(schema.attachments.dmMessageId, id))
|
||||||
|
.all();
|
||||||
|
|
||||||
// Delete attachments, reactions, and message atomically
|
// Delete attachments, reactions, and message atomically
|
||||||
db.transaction((tx) => {
|
db.transaction((tx) => {
|
||||||
tx.delete(schema.attachments)
|
tx.delete(schema.attachments)
|
||||||
@@ -918,6 +969,9 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.run();
|
.run();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clean up files from disk after transaction commits
|
||||||
|
deleteAttachmentFiles(attachmentRows);
|
||||||
|
|
||||||
// Broadcast to all DM members
|
// Broadcast to all DM members
|
||||||
const dmMembers = db.select()
|
const dmMembers = db.select()
|
||||||
.from(schema.dmMembers)
|
.from(schema.dmMembers)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
Reaction,
|
Reaction,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
|
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch reactions for a set of message IDs.
|
* Fetch reactions for a set of message IDs.
|
||||||
@@ -417,12 +418,21 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(403).send({ error: 'You cannot delete this message', statusCode: 403 });
|
return reply.code(403).send({ error: 'You cannot delete this message', statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect attachment filenames before deleting
|
||||||
|
const attachmentRows = db.select({ filename: schema.attachments.filename })
|
||||||
|
.from(schema.attachments)
|
||||||
|
.where(eq(schema.attachments.messageId, id))
|
||||||
|
.all();
|
||||||
|
|
||||||
// Delete attachments and message atomically
|
// Delete attachments and message atomically
|
||||||
db.transaction((tx) => {
|
db.transaction((tx) => {
|
||||||
tx.delete(schema.attachments).where(eq(schema.attachments.messageId, id)).run();
|
tx.delete(schema.attachments).where(eq(schema.attachments.messageId, id)).run();
|
||||||
tx.delete(schema.messages).where(eq(schema.messages.id, id)).run();
|
tx.delete(schema.messages).where(eq(schema.messages.id, id)).run();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clean up files from disk after transaction commits
|
||||||
|
deleteAttachmentFiles(attachmentRows);
|
||||||
|
|
||||||
// Broadcast deletion
|
// Broadcast deletion
|
||||||
connectionManager.sendToSpace(spaceId, {
|
connectionManager.sendToSpace(spaceId, {
|
||||||
type: 'message_deleted',
|
type: 'message_deleted',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { connectionManager } from '../ws/handler.js';
|
|||||||
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance } from '@backspace/shared';
|
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance } from '@backspace/shared';
|
||||||
import { AVATAR_COLORS } from '@backspace/shared';
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
|
import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||||
|
|
||||||
export async function userRoutes(app: FastifyInstance): Promise<void> {
|
export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
||||||
@@ -123,10 +124,21 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run all cleanup in a single transaction
|
|
||||||
db.transaction((tx) => {
|
|
||||||
const uid = request.userId;
|
const uid = request.userId;
|
||||||
|
|
||||||
|
// Collect file references before the transaction (avatar, banner)
|
||||||
|
const filesToDelete: string[] = [];
|
||||||
|
if (user.avatar) filesToDelete.push(user.avatar);
|
||||||
|
if (user.banner) filesToDelete.push(user.banner);
|
||||||
|
|
||||||
|
// Find group DMs this user owns so we can transfer ownership
|
||||||
|
const ownedGroupDms = db.select({ id: schema.dmChannels.id })
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.ownerId, uid))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Run all cleanup in a single transaction
|
||||||
|
db.transaction((tx) => {
|
||||||
// Remove from spaces, roles, friends, DMs, read states, reactions, folders, bans, join requests, voice restrictions, channel overrides
|
// Remove from spaces, roles, friends, DMs, read states, reactions, folders, bans, join requests, voice restrictions, channel overrides
|
||||||
tx.delete(schema.spaceMembers).where(eq(schema.spaceMembers.userId, uid)).run();
|
tx.delete(schema.spaceMembers).where(eq(schema.spaceMembers.userId, uid)).run();
|
||||||
tx.delete(schema.memberRoles).where(eq(schema.memberRoles.userId, uid)).run();
|
tx.delete(schema.memberRoles).where(eq(schema.memberRoles.userId, uid)).run();
|
||||||
@@ -143,11 +155,75 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
try { tx.delete(schema.joinRequests).where(eq(schema.joinRequests.userId, uid)).run(); } catch { /* table may not exist */ }
|
try { tx.delete(schema.joinRequests).where(eq(schema.joinRequests.userId, uid)).run(); } catch { /* table may not exist */ }
|
||||||
try { tx.delete(schema.voiceRestrictions).where(eq(schema.voiceRestrictions.userId, uid)).run(); } catch { /* table may not exist */ }
|
try { tx.delete(schema.voiceRestrictions).where(eq(schema.voiceRestrictions.userId, uid)).run(); } catch { /* table may not exist */ }
|
||||||
|
|
||||||
|
// Nullify moderator references pointing to this user
|
||||||
|
try {
|
||||||
|
tx.update(schema.bans).set({ bannedBy: null }).where(eq(schema.bans.bannedBy, uid)).run();
|
||||||
|
} catch { /* table may not exist */ }
|
||||||
|
try {
|
||||||
|
tx.update(schema.voiceRestrictions).set({ moderatorId: null }).where(eq(schema.voiceRestrictions.moderatorId, uid)).run();
|
||||||
|
} catch { /* table may not exist */ }
|
||||||
|
try {
|
||||||
|
tx.update(schema.joinRequests).set({ decidedBy: null }).where(eq(schema.joinRequests.decidedBy, uid)).run();
|
||||||
|
} catch { /* table may not exist */ }
|
||||||
|
|
||||||
// Remove member-type channel overrides for this user
|
// Remove member-type channel overrides for this user
|
||||||
tx.delete(schema.channelOverrides).where(
|
tx.delete(schema.channelOverrides).where(
|
||||||
and(eq(schema.channelOverrides.targetType, 'member'), eq(schema.channelOverrides.targetId, uid))
|
and(eq(schema.channelOverrides.targetType, 'member'), eq(schema.channelOverrides.targetId, uid))
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
|
// Transfer ownership of group DMs to the next remaining member
|
||||||
|
for (const { id: dmId } of ownedGroupDms) {
|
||||||
|
const nextMember = tx.select({ userId: schema.dmMembers.userId })
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.where(eq(schema.dmMembers.dmChannelId, dmId))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
if (nextMember) {
|
||||||
|
tx.update(schema.dmChannels)
|
||||||
|
.set({ ownerId: nextMember.userId })
|
||||||
|
.where(eq(schema.dmChannels.id, dmId))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up orphaned DM channels (zero members after our removal)
|
||||||
|
const orphanedDmIds = tx.select({ id: schema.dmChannels.id })
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.all()
|
||||||
|
.filter(dc => {
|
||||||
|
const memberCount = tx.select({ id: schema.dmMembers.dmChannelId })
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.where(eq(schema.dmMembers.dmChannelId, dc.id))
|
||||||
|
.all()
|
||||||
|
.length;
|
||||||
|
return memberCount === 0;
|
||||||
|
})
|
||||||
|
.map(dc => dc.id);
|
||||||
|
|
||||||
|
for (const dmId of orphanedDmIds) {
|
||||||
|
// Collect message IDs for this orphaned DM channel
|
||||||
|
const msgIds = tx.select({ id: schema.dmMessages.id })
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(eq(schema.dmMessages.dmChannelId, dmId))
|
||||||
|
.all()
|
||||||
|
.map(m => m.id);
|
||||||
|
|
||||||
|
if (msgIds.length > 0) {
|
||||||
|
// Collect attachment filenames for cleanup after tx
|
||||||
|
const dmAttachments = tx.select({ filename: schema.attachments.filename })
|
||||||
|
.from(schema.attachments)
|
||||||
|
.where(inArray(schema.attachments.dmMessageId, msgIds))
|
||||||
|
.all();
|
||||||
|
for (const att of dmAttachments) filesToDelete.push(att.filename);
|
||||||
|
|
||||||
|
// Delete attachments + reactions for all messages in this DM channel
|
||||||
|
tx.delete(schema.attachments).where(inArray(schema.attachments.dmMessageId, msgIds)).run();
|
||||||
|
tx.delete(schema.dmReactions).where(inArray(schema.dmReactions.dmMessageId, msgIds)).run();
|
||||||
|
}
|
||||||
|
// Delete the DM channel (cascades to dm_messages)
|
||||||
|
tx.delete(schema.dmChannels).where(eq(schema.dmChannels.id, dmId)).run();
|
||||||
|
}
|
||||||
|
|
||||||
// Tombstone user row — rename username to free it for reuse
|
// Tombstone user row — rename username to free it for reuse
|
||||||
tx.update(schema.users).set({
|
tx.update(schema.users).set({
|
||||||
username: `!deleted:${uid}`,
|
username: `!deleted:${uid}`,
|
||||||
@@ -166,6 +242,11 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}).where(eq(schema.users.id, uid)).run();
|
}).where(eq(schema.users.id, uid)).run();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clean up files from disk after transaction commits
|
||||||
|
for (const filename of filesToDelete) {
|
||||||
|
deleteUploadFile(filename);
|
||||||
|
}
|
||||||
|
|
||||||
// Force-close all WebSocket connections
|
// Force-close all WebSocket connections
|
||||||
connectionManager.forceDisconnectUser(request.userId);
|
connectionManager.forceDisconnectUser(request.userId);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single uploaded file by its stored filename.
|
||||||
|
* Uses path.basename() to prevent directory traversal attacks.
|
||||||
|
* Tolerates ENOENT (file already gone) but logs other errors.
|
||||||
|
*/
|
||||||
|
export function deleteUploadFile(filename: string): void {
|
||||||
|
const safeName = path.basename(filename);
|
||||||
|
const filePath = path.join(config.uploadDir, safeName);
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
console.error(`Failed to delete upload file ${safeName}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple uploaded files from disk.
|
||||||
|
* Accepts rows with a `filename` property (e.g. attachment query results).
|
||||||
|
*/
|
||||||
|
export function deleteAttachmentFiles(rows: { filename: string }[]): void {
|
||||||
|
for (const row of rows) {
|
||||||
|
deleteUploadFile(row.filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { AudioManager } from '../../audio/AudioManager';
|
|||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
import { parseFederatedUsername, isSelf } from '../../utils/identity';
|
import { parseFederatedUsername, isSelf } from '../../utils/identity';
|
||||||
import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice';
|
import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice';
|
||||||
|
import { ContextMenu } from '../ui/ContextMenu';
|
||||||
|
|
||||||
export function ChannelSidebar() {
|
export function ChannelSidebar() {
|
||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
@@ -208,7 +209,7 @@ export function ChannelSidebar() {
|
|||||||
? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ')
|
? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ')
|
||||||
: otherMembers[0]?.displayName ?? otherMembers[0]?.username;
|
: otherMembers[0]?.displayName ?? otherMembers[0]?.username;
|
||||||
|
|
||||||
return (
|
const dmItem = (
|
||||||
<div
|
<div
|
||||||
key={dm.id}
|
key={dm.id}
|
||||||
onClick={() => handleChannelClick(dm.id)}
|
onClick={() => handleChannelClick(dm.id)}
|
||||||
@@ -281,6 +282,36 @@ export function ChannelSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
return (
|
||||||
|
<ContextMenu
|
||||||
|
key={dm.id}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Leave Group',
|
||||||
|
danger: true,
|
||||||
|
icon: (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M10.09 15.59L11.5 17l5-5-5-5-1.41 1.41L12.67 11H3v2h9.67l-2.58 2.59zM19 3H5a2 2 0 00-2 2v4h2V5h14v14H5v-4H3v4a2 2 0 002 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
onClick: () => {
|
||||||
|
if (currentChannelId === dm.id) {
|
||||||
|
navigate('/channels/@me');
|
||||||
|
setCurrentChannel(null);
|
||||||
|
}
|
||||||
|
useSpaceStore.getState().leaveDm(dm.id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{dmItem}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dmItem;
|
||||||
})}
|
})}
|
||||||
{dmChannels.length === 0 && (
|
{dmChannels.length === 0 && (
|
||||||
<p className="px-2 py-4 text-[13px] text-txt-tertiary italic opacity-60">No DM conversations yet.</p>
|
<p className="px-2 py-4 text-[13px] text-txt-tertiary italic opacity-60">No DM conversations yet.</p>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ interface SpaceState {
|
|||||||
addDmMember: (dmChannelId: string, user: User) => void;
|
addDmMember: (dmChannelId: string, user: User) => void;
|
||||||
removeDmMember: (dmChannelId: string, userId: string) => void;
|
removeDmMember: (dmChannelId: string, userId: string) => void;
|
||||||
closeDm: (id: string) => Promise<void>;
|
closeDm: (id: string) => Promise<void>;
|
||||||
|
leaveDm: (id: string) => Promise<void>;
|
||||||
loadSpaces: () => Promise<void>;
|
loadSpaces: () => Promise<void>;
|
||||||
loadSpaceDetail: (spaceId: string) => Promise<void>;
|
loadSpaceDetail: (spaceId: string) => Promise<void>;
|
||||||
loadDmChannels: () => Promise<void>;
|
loadDmChannels: () => Promise<void>;
|
||||||
@@ -130,6 +131,15 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
leaveDm: async (id) => {
|
||||||
|
const origin = get().channelOriginMap.get(id) || '';
|
||||||
|
const targetApi = getApiForOrigin(origin);
|
||||||
|
await targetApi.dm.leave(id);
|
||||||
|
set((state) => ({
|
||||||
|
dmChannels: state.dmChannels.filter(c => c.id !== id)
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
loadSpaces: async () => {
|
loadSpaces: async () => {
|
||||||
try {
|
try {
|
||||||
const spaces =await api.spaces.list();
|
const spaces =await api.spaces.list();
|
||||||
|
|||||||
Reference in New Issue
Block a user