mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
511 lines
19 KiB
TypeScript
511 lines
19 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {randomInt} from 'node:crypto';
|
|
import {deleteRecoveryKit} from '@app/api/auth/AuthRecoveryKit';
|
|
import {revokeAllAuthSessions} from '@app/api/auth/AuthSessionRevocation';
|
|
import {createMessageID, createUserID, type MessageID, type UserID} from '@app/api/BrandedTypes';
|
|
import {Config} from '@app/api/Config';
|
|
import {mapChannelToResponse} from '@app/api/channel/ChannelMappers';
|
|
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
|
import {UserMessageDeletionService} from '@app/api/channel/services/message/UserMessageDeletionService';
|
|
import type {IConnectionRepository} from '@app/api/connection/IConnectionRepository';
|
|
import type {FavoriteMemeRepository} from '@app/api/favorite_meme/FavoriteMemeRepository';
|
|
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
|
|
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
|
|
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
|
|
import type {DiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
|
|
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
|
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
|
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
|
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
|
import {Logger} from '@app/api/Logger';
|
|
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
|
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
|
|
import type {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepository';
|
|
import type {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2TokenRepository';
|
|
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
|
|
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
|
import {isPendingDeletionBlocked} from '@app/api/user/services/PendingDeletionCoordinator';
|
|
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
|
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
|
import {
|
|
DELETED_USER_DISCRIMINATOR,
|
|
DELETED_USER_GLOBAL_NAME,
|
|
DELETED_USER_USERNAME,
|
|
ProfileFieldPrivacyFlags,
|
|
UserFlags,
|
|
} from '@fluxer/constants/src/UserConstants';
|
|
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
|
|
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
|
import {ms} from 'itty-time';
|
|
import type Stripe from 'stripe';
|
|
|
|
const CHUNK_SIZE = 100;
|
|
|
|
interface UserDeletionDependencies {
|
|
userRepository: UserRepository;
|
|
guildRepository: GuildRepository;
|
|
channelRepository: ChannelRepository;
|
|
favoriteMemeRepository: FavoriteMemeRepository;
|
|
oauth2TokenRepository: OAuth2TokenRepository;
|
|
storageService: IStorageService;
|
|
purgeQueue: IPurgeQueue;
|
|
userCacheService: UserCacheService;
|
|
gatewayService: IGatewayService;
|
|
snowflakeService: ISnowflakeService;
|
|
discriminatorService: DiscriminatorService;
|
|
stripe: Stripe | null;
|
|
applicationRepository: ApplicationRepository;
|
|
workerService: IWorkerService<WorkerTaskName>;
|
|
connectionRepository: IConnectionRepository;
|
|
storeEntitlementService: StoreEntitlementService;
|
|
}
|
|
|
|
export async function processUserDeletion(
|
|
userId: UserID,
|
|
pendingDeletionAt: Date,
|
|
deletionReasonCode: number,
|
|
deps: UserDeletionDependencies,
|
|
): Promise<void> {
|
|
const {
|
|
userRepository,
|
|
guildRepository,
|
|
channelRepository,
|
|
favoriteMemeRepository,
|
|
oauth2TokenRepository,
|
|
storageService,
|
|
purgeQueue,
|
|
userCacheService,
|
|
gatewayService,
|
|
snowflakeService,
|
|
stripe,
|
|
applicationRepository,
|
|
workerService,
|
|
connectionRepository,
|
|
storeEntitlementService,
|
|
} = deps;
|
|
Logger.debug({userId, deletionReasonCode}, 'Starting user account deletion');
|
|
const scheduledUser = await userRepository.findUnique(userId);
|
|
if (
|
|
!scheduledUser ||
|
|
scheduledUser.pendingDeletionAt?.getTime() !== pendingDeletionAt.getTime() ||
|
|
(!scheduledUser.deletionStartedAt &&
|
|
(pendingDeletionAt.getTime() > Date.now() || isPendingDeletionBlocked(scheduledUser)))
|
|
) {
|
|
Logger.info({userId, pendingDeletionAt}, 'Account deletion schedule is no longer eligible');
|
|
return;
|
|
}
|
|
if (scheduledUser.stripeSubscriptionId && stripe) {
|
|
const MAX_RETRIES = 3;
|
|
let lastError: unknown = null;
|
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
try {
|
|
Logger.debug(
|
|
{userId, subscriptionId: scheduledUser.stripeSubscriptionId, attempt},
|
|
'Canceling active Stripe subscription',
|
|
);
|
|
const canceledSubscription = await stripe.subscriptions.cancel(scheduledUser.stripeSubscriptionId, {
|
|
invoice_now: false,
|
|
prorate: false,
|
|
});
|
|
try {
|
|
await getBillingRepository().subscriptions.upsertFromStripe(canceledSubscription, {
|
|
knownUserId: scheduledUser.id,
|
|
snapshotCapturedAt: new Date(),
|
|
});
|
|
} catch (mirrorErr) {
|
|
Logger.error(
|
|
{mirrorErr, subId: canceledSubscription.id},
|
|
'Mirror upsert failed after Stripe write; reconciler will heal',
|
|
);
|
|
}
|
|
Logger.debug(
|
|
{userId, subscriptionId: scheduledUser.stripeSubscriptionId},
|
|
'Stripe subscription cancelled successfully',
|
|
);
|
|
lastError = null;
|
|
break;
|
|
} catch (error) {
|
|
lastError = error;
|
|
const isLastAttempt = attempt === MAX_RETRIES - 1;
|
|
Logger.error(
|
|
{
|
|
error,
|
|
userId,
|
|
subscriptionId: scheduledUser.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 + randomInt(500);
|
|
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
|
|
}
|
|
}
|
|
}
|
|
if (lastError) {
|
|
const error = new Error(
|
|
`Failed to cancel Stripe subscription ${scheduledUser.stripeSubscriptionId} for user ${userId} after ${MAX_RETRIES} attempts. User deletion halted to prevent billing issues.`,
|
|
{cause: lastError},
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
const user = await userRepository.startDeletion(userId, pendingDeletionAt);
|
|
if (!user) {
|
|
Logger.info({userId, pendingDeletionAt}, 'Account deletion schedule is no longer eligible');
|
|
return;
|
|
}
|
|
await storeEntitlementService.stopBillingForDeletedUser(userId);
|
|
await connectionRepository.sealAndDeleteForUser(userId);
|
|
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: false,
|
|
email: null,
|
|
email_verified: null,
|
|
email_bounced: 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,
|
|
timezone: null,
|
|
timezone_privacy_flags: ProfileFieldPrivacyFlags.EVERYONE,
|
|
date_of_birth: null,
|
|
locale: null,
|
|
flags: UserFlags.DELETED,
|
|
premium_type: null,
|
|
premium_since: null,
|
|
premium_until: null,
|
|
premium_gift_extension_ends_at: null,
|
|
premium_will_cancel: null,
|
|
premium_billing_cycle: null,
|
|
premium_lifetime_sequence: null,
|
|
premium_grace_ends_at: null,
|
|
stripe_subscription_id: null,
|
|
stripe_customer_id: null,
|
|
has_ever_purchased: 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,
|
|
mention_flags: null,
|
|
last_voice_activity_sharing_change_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 member cleanup');
|
|
} else {
|
|
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) {
|
|
await guildRepository.upsertPartial(
|
|
guildId,
|
|
{member_count: Math.max(0, guild.memberCount - 1)},
|
|
guild.toRow(),
|
|
);
|
|
}
|
|
await gatewayService.dispatchGuild({
|
|
guildId,
|
|
event: 'GUILD_MEMBER_REMOVE',
|
|
data: {user: {id: userId.toString()}},
|
|
});
|
|
}
|
|
} catch (error) {
|
|
Logger.error({error, userId, guildId}, 'Failed to remove user from guild membership');
|
|
}
|
|
try {
|
|
await gatewayService.leaveGuild({userId, guildId});
|
|
Logger.debug({userId, guildId}, 'Left guild successfully');
|
|
} catch (error) {
|
|
Logger.error({error, userId, guildId}, 'Failed to leave guild in gateway');
|
|
}
|
|
}
|
|
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.generateForChannel(channel.id));
|
|
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');
|
|
}
|
|
}
|
|
if (user.pendingBulkMessageDeletionAt) {
|
|
const deleted = await new UserMessageDeletionService({
|
|
channelRepository,
|
|
gatewayService,
|
|
storageService,
|
|
purgeQueue,
|
|
workerService,
|
|
}).deleteUserMessagesBulk(userId);
|
|
await userRepository.patchUpsert(
|
|
userId,
|
|
{
|
|
pending_bulk_message_deletion_at: null,
|
|
pending_bulk_message_deletion_channel_count: null,
|
|
pending_bulk_message_deletion_message_count: null,
|
|
},
|
|
user.toRow(),
|
|
);
|
|
Logger.debug({userId, deleted}, 'Deleted messages scheduled for deletion before anonymizing the rest');
|
|
}
|
|
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([
|
|
guildRepository.deleteAllBansForUser(userId),
|
|
userRepository.deleteUserSettings(userId),
|
|
userRepository.deleteAllUserGuildSettings(userId),
|
|
userRepository.deleteAllRelationships(userId),
|
|
userRepository.deleteAllNotes(userId),
|
|
userRepository.deleteAllReadStates(userId),
|
|
userRepository.deleteAllSavedMessages(userId),
|
|
revokeAllAuthSessions({users: userRepository, gateway: gatewayService}, userId),
|
|
userRepository.deleteAllMfaBackupCodes(userId),
|
|
userRepository.deleteAllWebAuthnCredentials(userId),
|
|
deleteRecoveryKit(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.anonymizeForDeletion(userForAnonymization, {
|
|
username: DELETED_USER_USERNAME,
|
|
discriminator: DELETED_USER_DISCRIMINATOR,
|
|
global_name: DELETED_USER_GLOBAL_NAME,
|
|
email: null,
|
|
email_verified: false,
|
|
password_hash: null,
|
|
totp_secret: null,
|
|
avatar_hash: null,
|
|
banner_hash: null,
|
|
bio: null,
|
|
pronouns: null,
|
|
accent_color: null,
|
|
timezone: null,
|
|
timezone_privacy_flags: ProfileFieldPrivacyFlags.EVERYONE,
|
|
date_of_birth: null,
|
|
flags: UserFlags.DELETED,
|
|
premium_type: null,
|
|
premium_since: null,
|
|
premium_until: null,
|
|
premium_gift_extension_ends_at: null,
|
|
stripe_customer_id: null,
|
|
stripe_subscription_id: null,
|
|
authenticator_types: new Set(),
|
|
});
|
|
await userRepository.removePendingDeletion(userId, pendingDeletionAt);
|
|
await userCacheService.setUserPartialResponseFromUser(anonymisedUser);
|
|
await userRepository.completeDeletion(anonymisedUser);
|
|
await emitActivity('account_deleted', userId.toString(), {user_id: userId.toString()}, null, userId.toString());
|
|
Logger.debug({userId, deletionReasonCode}, 'User account anonymization completed successfully');
|
|
}
|