520 lines
16 KiB
TypeScript
520 lines
16 KiB
TypeScript
/*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
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<void> {
|
|
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<UserID>(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',
|
|
},
|
|
});
|
|
}
|