/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {createMessageID, createUserID, type MessageID, type UserID} from '@fluxer/api/src/BrandedTypes'; import {Config} from '@fluxer/api/src/Config'; import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers'; import type {ChannelRepository} from '@fluxer/api/src/channel/ChannelRepository'; import type {FavoriteMemeRepository} from '@fluxer/api/src/favorite_meme/FavoriteMemeRepository'; import type {GuildRepository} from '@fluxer/api/src/guild/repositories/GuildRepository'; import type {IPurgeQueue} from '@fluxer/api/src/infrastructure/CloudflarePurgeQueue'; import type {DiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService'; import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService'; import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService'; import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService'; import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService'; import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService'; import {Logger} from '@fluxer/api/src/Logger'; import {createRequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware'; import type {ApplicationRepository} from '@fluxer/api/src/oauth/repositories/ApplicationRepository'; import type {OAuth2TokenRepository} from '@fluxer/api/src/oauth/repositories/OAuth2TokenRepository'; import type {UserRepository} from '@fluxer/api/src/user/repositories/UserRepository'; import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; import { DELETED_USER_DISCRIMINATOR, DELETED_USER_GLOBAL_NAME, DELETED_USER_USERNAME, UserFlags, } from '@fluxer/constants/src/UserConstants'; import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets'; import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService'; import {ms} from 'itty-time'; import type Stripe from 'stripe'; const CHUNK_SIZE = 100; export interface UserDeletionDependencies { userRepository: UserRepository; guildRepository: GuildRepository; channelRepository: ChannelRepository; favoriteMemeRepository: FavoriteMemeRepository; oauth2TokenRepository: OAuth2TokenRepository; storageService: IStorageService; purgeQueue: IPurgeQueue; userCacheService: UserCacheService; gatewayService: IGatewayService; snowflakeService: SnowflakeService; discriminatorService: DiscriminatorService; stripe: Stripe | null; applicationRepository: ApplicationRepository; workerService: IWorkerService; } export async function processUserDeletion( userId: UserID, deletionReasonCode: number, deps: UserDeletionDependencies, ): Promise { const { userRepository, guildRepository, channelRepository, favoriteMemeRepository, oauth2TokenRepository, storageService, purgeQueue, userCacheService, gatewayService, snowflakeService, stripe, applicationRepository, workerService, } = deps; Logger.debug({userId, deletionReasonCode}, 'Starting user account deletion'); const user = await userRepository.findUnique(userId); if (!user) { Logger.warn({userId}, 'User not found, skipping deletion'); return; } if (user.stripeSubscriptionId && stripe) { const MAX_RETRIES = 3; let lastError: unknown = null; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { Logger.debug( {userId, subscriptionId: user.stripeSubscriptionId, attempt}, 'Canceling active Stripe subscription', ); await stripe.subscriptions.cancel(user.stripeSubscriptionId, { invoice_now: false, prorate: false, }); Logger.debug({userId, subscriptionId: user.stripeSubscriptionId}, 'Stripe subscription cancelled successfully'); lastError = null; break; } catch (error) { lastError = error; const isLastAttempt = attempt === MAX_RETRIES - 1; Logger.error( { error, userId, subscriptionId: user.stripeSubscriptionId, attempt: attempt + 1, maxRetries: MAX_RETRIES, willRetry: !isLastAttempt, }, isLastAttempt ? 'Failed to cancel Stripe subscription after all retries' : 'Failed to cancel Stripe subscription, retrying with exponential backoff', ); if (!isLastAttempt) { const backoffDelay = ms('1 second') * 2 ** attempt + Math.random() * 500; await new Promise((resolve) => setTimeout(resolve, backoffDelay)); } } } if (lastError) { const error = new Error( `Failed to cancel Stripe subscription ${user.stripeSubscriptionId} for user ${userId} after ${MAX_RETRIES} attempts. User deletion halted to prevent billing issues.`, {cause: lastError}, ); throw error; } } const deletedUserId = createUserID(await snowflakeService.generate()); Logger.debug({userId, deletedUserId}, 'Creating dedicated deleted user record'); await userRepository.create({ user_id: deletedUserId, username: DELETED_USER_USERNAME, discriminator: DELETED_USER_DISCRIMINATOR, global_name: DELETED_USER_GLOBAL_NAME, bot: false, system: true, email: null, email_verified: null, email_bounced: null, phone: null, password_hash: null, password_last_changed_at: null, totp_secret: null, authenticator_types: null, avatar_hash: null, avatar_color: null, banner_hash: null, banner_color: null, bio: null, pronouns: null, accent_color: null, date_of_birth: null, locale: null, flags: UserFlags.DELETED, premium_type: null, premium_since: null, premium_until: null, premium_will_cancel: null, premium_billing_cycle: null, premium_lifetime_sequence: null, stripe_subscription_id: null, stripe_customer_id: null, has_ever_purchased: null, suspicious_activity_flags: null, terms_agreed_at: null, privacy_agreed_at: null, last_active_at: null, last_active_ip: null, temp_banned_until: null, pending_deletion_at: null, pending_bulk_message_deletion_at: null, pending_bulk_message_deletion_channel_count: null, pending_bulk_message_deletion_message_count: null, deletion_reason_code: null, deletion_public_reason: null, deletion_audit_log_reason: null, acls: null, traits: null, first_refund_at: null, gift_inventory_server_seq: null, gift_inventory_client_seq: null, premium_onboarding_dismissed_at: null, version: 1, }); await userRepository.deleteUserSecondaryIndices(deletedUserId); Logger.debug({userId}, 'Leaving all guilds'); const guildIds = await userRepository.getUserGuildIds(userId); for (const guildId of guildIds) { try { const member = await guildRepository.getMember(guildId, userId); if (!member) { Logger.debug({userId, guildId}, 'Member not found in guild, skipping'); continue; } if (member.avatarHash) { try { const key = `guilds/${guildId}/users/${userId}/avatars/${member.avatarHash}`; await storageService.deleteObject(Config.s3.buckets.cdn, key); await purgeQueue.addUrls([`${Config.endpoints.media}/${key}`]); } catch (error) { Logger.error({error, userId, guildId, avatarHash: member.avatarHash}, 'Failed to delete guild member avatar'); } } if (member.bannerHash) { try { const key = `guilds/${guildId}/users/${userId}/banners/${member.bannerHash}`; await storageService.deleteObject(Config.s3.buckets.cdn, key); await purgeQueue.addUrls([`${Config.endpoints.media}/${key}`]); } catch (error) { Logger.error({error, userId, guildId, bannerHash: member.bannerHash}, 'Failed to delete guild member banner'); } } await guildRepository.deleteMember(guildId, userId); const guild = await guildRepository.findUnique(guildId); if (guild) { const guildRow = guild.toRow(); await guildRepository.upsert({ ...guildRow, member_count: Math.max(0, guild.memberCount - 1), }); } await gatewayService.dispatchGuild({ guildId, event: 'GUILD_MEMBER_REMOVE', data: {user: {id: userId.toString()}}, }); await gatewayService.leaveGuild({userId, guildId}); Logger.debug({userId, guildId}, 'Left guild successfully'); } catch (error) { Logger.error({error, userId, guildId}, 'Failed to leave guild'); } } Logger.debug({userId}, 'Leaving all group DMs'); const allPrivateChannels = await userRepository.listPrivateChannels(userId); const groupDmChannels = allPrivateChannels.filter((channel) => channel.type === ChannelTypes.GROUP_DM); for (const channel of groupDmChannels) { try { const updatedRecipientIds = new Set(channel.recipientIds); updatedRecipientIds.delete(userId); let newOwnerId = channel.ownerId; if (userId === channel.ownerId && updatedRecipientIds.size > 0) { newOwnerId = Array.from(updatedRecipientIds)[0]; } if (updatedRecipientIds.size === 0) { await channelRepository.delete(channel.id); await userRepository.closeDmForUser(userId, channel.id); const channelResponse = await mapChannelToResponse({ channel, currentUserId: null, userCacheService, requestCache: createRequestCache(), }); await gatewayService.dispatchPresence({ userId, event: 'CHANNEL_DELETE', data: channelResponse, }); Logger.debug({userId, channelId: channel.id}, 'Deleted empty group DM'); continue; } const updatedNicknames = new Map(channel.nicknames); updatedNicknames.delete(userId.toString()); await channelRepository.upsert({ ...channel.toRow(), owner_id: newOwnerId, recipient_ids: updatedRecipientIds, nicks: updatedNicknames.size > 0 ? updatedNicknames : null, }); await userRepository.closeDmForUser(userId, channel.id); const messageId = createMessageID(await snowflakeService.generate()); await channelRepository.upsertMessage({ channel_id: channel.id, bucket: BucketUtils.makeBucket(messageId), message_id: messageId, author_id: userId, type: MessageTypes.RECIPIENT_REMOVE, webhook_id: null, webhook_name: null, webhook_avatar_hash: null, content: null, edited_timestamp: null, pinned_timestamp: null, flags: 0, mention_everyone: false, mention_users: new Set([userId]), mention_roles: null, mention_channels: null, attachments: null, embeds: null, sticker_items: null, message_reference: null, message_snapshots: null, call: null, has_reaction: false, version: 1, }); const recipientUserResponse = await userCacheService.getUserPartialResponse(userId, createRequestCache()); for (const recId of updatedRecipientIds) { await gatewayService.dispatchPresence({ userId: recId, event: 'CHANNEL_RECIPIENT_REMOVE', data: { channel_id: channel.id.toString(), user: recipientUserResponse, }, }); } const channelResponse = await mapChannelToResponse({ channel, currentUserId: null, userCacheService, requestCache: createRequestCache(), }); await gatewayService.dispatchPresence({ userId, event: 'CHANNEL_DELETE', data: channelResponse, }); Logger.debug({userId, channelId: channel.id}, 'Left group DM successfully'); } catch (error) { Logger.error({error, userId, channelId: channel.id}, 'Failed to leave group DM'); } } Logger.debug({userId}, 'Anonymizing user messages'); let lastMessageId: MessageID | undefined; let processedCount = 0; while (true) { const messagesToAnonymize = await channelRepository.listMessagesByAuthor(userId, CHUNK_SIZE, lastMessageId); if (messagesToAnonymize.length === 0) { break; } for (const {channelId, messageId} of messagesToAnonymize) { await channelRepository.anonymizeMessage(channelId, messageId, deletedUserId); } processedCount += messagesToAnonymize.length; lastMessageId = messagesToAnonymize[messagesToAnonymize.length - 1].messageId; Logger.debug({userId, processedCount, chunkSize: messagesToAnonymize.length}, 'Anonymized message chunk'); if (messagesToAnonymize.length < CHUNK_SIZE) { break; } } Logger.debug({userId, totalProcessed: processedCount}, 'Completed message anonymization'); Logger.debug({userId}, 'Deleting S3 objects'); if (user.avatarHash) { try { await storageService.deleteAvatar({prefix: 'avatars', key: `${userId}/${user.avatarHash}`}); await purgeQueue.addUrls([`${Config.endpoints.media}/avatars/${userId}/${user.avatarHash}`]); Logger.debug({userId, avatarHash: user.avatarHash}, 'Deleted avatar'); } catch (error) { Logger.error({error, userId}, 'Failed to delete avatar'); } } if (user.bannerHash) { try { await storageService.deleteAvatar({prefix: 'banners', key: `${userId}/${user.bannerHash}`}); await purgeQueue.addUrls([`${Config.endpoints.media}/banners/${userId}/${user.bannerHash}`]); Logger.debug({userId, bannerHash: user.bannerHash}, 'Deleted banner'); } catch (error) { Logger.error({error, userId}, 'Failed to delete banner'); } } const favoriteMemes = await favoriteMemeRepository.findByUserId(userId); for (const meme of favoriteMemes) { try { await storageService.deleteObject(Config.s3.buckets.cdn, meme.storageKey); Logger.debug({userId, memeId: meme.id}, 'Deleted favorite meme'); } catch (error) { Logger.error({error, userId, memeId: meme.id}, 'Failed to delete favorite meme'); } } await favoriteMemeRepository.deleteAllByUserId(userId); Logger.debug({userId}, 'Deleting OAuth tokens'); await Promise.all([ oauth2TokenRepository.deleteAllAccessTokensForUser(userId), oauth2TokenRepository.deleteAllRefreshTokensForUser(userId), ]); Logger.debug({userId}, 'Deleting owned developer applications and bots'); try { const applications = await applicationRepository.listApplicationsByOwner(userId); for (const application of applications) { await workerService.addJob('applicationProcessDeletion', { applicationId: application.applicationId.toString(), }); } Logger.debug({userId, applicationCount: applications.length}, 'Scheduled application deletions'); } catch (error) { Logger.error({error, userId}, 'Failed to schedule application deletions'); } Logger.debug({userId}, 'Deleting user data'); await Promise.all([ userRepository.deleteUserSettings(userId), userRepository.deleteAllUserGuildSettings(userId), userRepository.deleteAllRelationships(userId), userRepository.deleteAllNotes(userId), userRepository.deleteAllReadStates(userId), userRepository.deleteAllSavedMessages(userId), userRepository.deleteAllAuthSessions(userId), userRepository.deleteAllMfaBackupCodes(userId), userRepository.deleteAllWebAuthnCredentials(userId), userRepository.deleteAllPushSubscriptions(userId), userRepository.deleteAllRecentMentions(userId), userRepository.deleteAllAuthorizedIps(userId), userRepository.deletePinnedDmsByUserId(userId), ]); await userRepository.deleteUserSecondaryIndices(userId); const userForAnonymization = await userRepository.findUniqueAssert(userId); Logger.debug({userId}, 'Anonymizing user record'); const anonymisedUser = await userRepository.patchUpsert( userId, { username: DELETED_USER_USERNAME, discriminator: DELETED_USER_DISCRIMINATOR, global_name: DELETED_USER_GLOBAL_NAME, email: null, email_verified: false, phone: null, password_hash: null, totp_secret: null, avatar_hash: null, banner_hash: null, bio: null, pronouns: null, accent_color: null, date_of_birth: null, flags: UserFlags.DELETED, premium_type: null, premium_since: null, premium_until: null, stripe_customer_id: null, stripe_subscription_id: null, pending_deletion_at: null, authenticator_types: new Set(), }, userForAnonymization.toRow(), ); await userCacheService.setUserPartialResponseFromUser(anonymisedUser); Logger.debug({userId, deletionReasonCode}, 'User account anonymization completed successfully'); getMetricsService().counter({ name: 'user.deletion', dimensions: { reason_code: deletionReasonCode.toString(), source: 'worker', }, }); }