diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 255ddef3..2715bf34 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -201,6 +201,9 @@ export function runMigrations(db: Database.Database): void { // ─── Free usernames from already-tombstoned users ─────────────────────────── migrateDeletedUsernames(db); + // ─── Clean up orphaned data from deleted users and channels ──────────────── + migrateOrphanedData(db); + 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" * Frees plain usernames for native user creation and makes all federated users diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 07f1bd5a..d85a4fd0 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -69,7 +69,7 @@ export const messages = sqliteTable('messages', { export const attachments = sqliteTable('attachments', { id: text('id').primaryKey(), 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(), originalName: text('original_name').notNull(), mimetype: text('mimetype').notNull(), @@ -127,7 +127,7 @@ export const reactions = sqliteTable('reactions', { export const dmReactions = sqliteTable('dm_reactions', { 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' }), emoji: text('emoji').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' }), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), reason: text('reason'), - bannedBy: text('banned_by').notNull().references(() => users.id), + bannedBy: text('banned_by').references(() => users.id), createdAt: integer('created_at').notNull(), }, (table) => ({ 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' }), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), 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(), }, (table) => ({ pk: primaryKey({ columns: [table.spaceId, table.userId, table.restrictionType] }), diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index 226c1d4b..9cd3040c 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -17,6 +17,7 @@ import type { Reaction, } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; +import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js'; /** * Batch-fetch reactions for a set of DM message IDs. @@ -638,6 +639,9 @@ export async function dmRoutes(app: FastifyInstance): Promise { 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 db.delete(schema.dmMembers) .where(and( @@ -646,18 +650,59 @@ export async function dmRoutes(app: FastifyInstance): Promise { )) .run(); - // Broadcast dm_member_removed to remaining members + // Check remaining members const remainingMembers = db.select() .from(schema.dmMembers) .where(eq(schema.dmMembers.dmChannelId, id)) .all(); - for (const member of remainingMembers) { - connectionManager.sendToUser(member.userId, { - type: 'dm_member_removed', - dmChannelId: id, - userId: request.userId, - }); + 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) { + connectionManager.sendToUser(member.userId, { + type: 'dm_member_removed', + dmChannelId: id, + 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 @@ -903,6 +948,12 @@ export async function dmRoutes(app: FastifyInstance): Promise { 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 db.transaction((tx) => { tx.delete(schema.attachments) @@ -918,6 +969,9 @@ export async function dmRoutes(app: FastifyInstance): Promise { .run(); }); + // Clean up files from disk after transaction commits + deleteAttachmentFiles(attachmentRows); + // Broadcast to all DM members const dmMembers = db.select() .from(schema.dmMembers) diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts index d0e618a3..f75b985a 100644 --- a/packages/server/src/routes/messages.ts +++ b/packages/server/src/routes/messages.ts @@ -13,6 +13,7 @@ import type { Reaction, } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; +import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; /** * Fetch reactions for a set of message IDs. @@ -417,12 +418,21 @@ export async function messageRoutes(app: FastifyInstance): Promise { 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 db.transaction((tx) => { tx.delete(schema.attachments).where(eq(schema.attachments.messageId, 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 connectionManager.sendToSpace(spaceId, { type: 'message_deleted', diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 266b5d58..fb847b76 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -7,6 +7,7 @@ import { connectionManager } from '../ws/handler.js'; import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance } from '@backspace/shared'; import { AVATAR_COLORS } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; +import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js'; export async function userRoutes(app: FastifyInstance): Promise { app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => { @@ -123,10 +124,21 @@ export async function userRoutes(app: FastifyInstance): Promise { }); } + 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) => { - const uid = request.userId; - // 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.memberRoles).where(eq(schema.memberRoles.userId, uid)).run(); @@ -143,11 +155,75 @@ export async function userRoutes(app: FastifyInstance): Promise { 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 */ } + // 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 tx.delete(schema.channelOverrides).where( and(eq(schema.channelOverrides.targetType, 'member'), eq(schema.channelOverrides.targetId, uid)) ).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 tx.update(schema.users).set({ username: `!deleted:${uid}`, @@ -166,6 +242,11 @@ export async function userRoutes(app: FastifyInstance): Promise { }).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 connectionManager.forceDisconnectUser(request.userId); diff --git a/packages/server/src/utils/fileCleanup.ts b/packages/server/src/utils/fileCleanup.ts new file mode 100644 index 00000000..096a858d --- /dev/null +++ b/packages/server/src/utils/fileCleanup.ts @@ -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); + } +} diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 575698e7..0732e1f0 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -15,6 +15,7 @@ import { AudioManager } from '../../audio/AudioManager'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { parseFederatedUsername, isSelf } from '../../utils/identity'; import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice'; +import { ContextMenu } from '../ui/ContextMenu'; export function ChannelSidebar() { const spaces = useSpaceStore((s) => s.spaces); @@ -208,7 +209,7 @@ export function ChannelSidebar() { ? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ') : otherMembers[0]?.displayName ?? otherMembers[0]?.username; - return ( + const dmItem = (
handleChannelClick(dm.id)} @@ -281,6 +282,36 @@ export function ChannelSidebar() {
); + + if (isGroup) { + return ( + + + + ), + onClick: () => { + if (currentChannelId === dm.id) { + navigate('/channels/@me'); + setCurrentChannel(null); + } + useSpaceStore.getState().leaveDm(dm.id); + }, + }, + ]} + > + {dmItem} + + ); + } + + return dmItem; })} {dmChannels.length === 0 && (

No DM conversations yet.

diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index 06ada068..cbefdc32 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -46,6 +46,7 @@ interface SpaceState { addDmMember: (dmChannelId: string, user: User) => void; removeDmMember: (dmChannelId: string, userId: string) => void; closeDm: (id: string) => Promise; + leaveDm: (id: string) => Promise; loadSpaces: () => Promise; loadSpaceDetail: (spaceId: string) => Promise; loadDmChannels: () => Promise; @@ -130,6 +131,15 @@ export const useSpaceStore = create((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 () => { try { const spaces =await api.spaces.list();