refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,56 @@
/*
* 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 type {UserID} from '@fluxer/api/src/BrandedTypes';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {User} from '@fluxer/api/src/models/User';
import {invalidateUserCache, updateUserCache} from '@fluxer/api/src/user/UserCacheHelpers';
import {mapUserToPrivateResponse} from '@fluxer/api/src/user/UserMappers';
export interface BaseUserUpdatePropagatorDeps {
userCacheService: UserCacheService;
gatewayService: IGatewayService;
}
export class BaseUserUpdatePropagator {
constructor(protected readonly baseDeps: BaseUserUpdatePropagatorDeps) {}
async dispatchUserUpdate(user: User): Promise<void> {
await this.baseDeps.gatewayService.dispatchPresence({
userId: user.id,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(user),
});
}
async invalidateUserCache(userId: UserID): Promise<void> {
await invalidateUserCache({
userId,
userCacheService: this.baseDeps.userCacheService,
});
}
async updateUserCache(user: User): Promise<void> {
await updateUserCache({
user,
userCacheService: this.baseDeps.userCacheService,
});
}
}

View File

@@ -0,0 +1,112 @@
/*
* 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 {createEmojiID, type EmojiID, type UserID} from '@fluxer/api/src/BrandedTypes';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {PackService} from '@fluxer/api/src/pack/PackService';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {CustomStatusPayload} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {z} from 'zod';
export interface ValidatedCustomStatus {
text: string | null;
expiresAt: Date | null;
emojiId: EmojiID | null;
emojiName: string | null;
emojiAnimated: boolean;
}
export class CustomStatusValidator {
constructor(
private readonly userAccountRepository: IUserAccountRepository,
private readonly guildRepository: IGuildRepositoryAggregate,
private readonly packService: PackService,
private readonly limitConfigService: LimitConfigService,
) {}
async validate(userId: UserID, payload: z.infer<typeof CustomStatusPayload>): Promise<ValidatedCustomStatus> {
const text = payload.text ?? null;
const expiresAt = payload.expires_at ?? null;
let emojiId: EmojiID | null = null;
let emojiName: string | null = null;
let emojiAnimated = false;
if (payload.emoji_id != null) {
emojiId = createEmojiID(payload.emoji_id);
const emoji = await this.guildRepository.getEmojiById(emojiId);
if (!emoji) {
throw InputValidationError.fromCode('custom_status.emoji_id', ValidationErrorCodes.CUSTOM_EMOJI_NOT_FOUND);
}
const user = await this.userAccountRepository.findUnique(userId);
const ctx = createLimitMatchContext({user});
const hasGlobalExpressions = resolveLimitSafe(
this.limitConfigService.getConfigSnapshot(),
ctx,
'feature_global_expressions',
0,
);
if (hasGlobalExpressions === 0) {
throw InputValidationError.fromCode(
'custom_status.emoji_id',
ValidationErrorCodes.PREMIUM_REQUIRED_FOR_CUSTOM_EMOJI,
);
}
const guildMember = await this.guildRepository.getMember(emoji.guildId, userId);
let hasAccess = guildMember !== null;
if (!hasAccess) {
const resolver = await this.packService.createPackExpressionAccessResolver({
userId,
type: 'emoji',
});
const resolution = await resolver.resolve(emoji.guildId);
hasAccess = resolution === 'accessible';
}
if (!hasAccess) {
throw InputValidationError.fromCode(
'custom_status.emoji_id',
ValidationErrorCodes.EMOJI_REQUIRES_GUILD_OR_PACK_ACCESS,
);
}
emojiName = emoji.name;
emojiAnimated = emoji.isAnimated;
} else if (payload.emoji_name != null) {
emojiName = payload.emoji_name;
}
return {
text,
expiresAt,
emojiId,
emojiName,
emojiAnimated,
};
}
}

View File

@@ -0,0 +1,411 @@
/*
* 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 crypto from 'node:crypto';
import {EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS} from '@fluxer/api/src/auth/services/AuthEmailService';
import type {User} from '@fluxer/api/src/models/User';
import type {EmailChangeRepository} from '@fluxer/api/src/user/repositories/auth/EmailChangeRepository';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import {ms} from 'itty-time';
export interface StartEmailChangeResult {
ticket: string;
require_original: boolean;
original_email?: string | null;
original_proof?: string | null;
original_code_expires_at?: string | null;
resend_available_at?: string | null;
}
export interface VerifyOriginalResult {
original_proof: string;
}
export interface RequestNewEmailResult {
ticket: string;
new_email: string;
new_code_expires_at: string;
resend_available_at: string | null;
}
export class EmailChangeService {
private readonly ORIGINAL_CODE_TTL_MS = ms('10 minutes');
private readonly NEW_CODE_TTL_MS = ms('10 minutes');
private readonly TOKEN_TTL_MS = ms('30 minutes');
private readonly RESEND_COOLDOWN_MS = ms('30 seconds');
constructor(
private readonly repo: EmailChangeRepository,
private readonly emailService: IEmailService,
private readonly userAccountRepository: IUserAccountRepository,
private readonly rateLimitService: IRateLimitService,
) {}
async start(user: User): Promise<StartEmailChangeResult> {
const isUnclaimed = user.isUnclaimedAccount();
const hasEmail = !!user.email;
if (!hasEmail && !isUnclaimed) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.MUST_HAVE_EMAIL_TO_CHANGE_IT);
}
const ticket = this.generateTicket();
const requireOriginal = !!user.emailVerified && hasEmail;
const now = new Date();
let originalCode: string | null = null;
let originalCodeExpiresAt: Date | null = null;
let originalCodeSentAt: Date | null = null;
if (requireOriginal) {
await this.ensureRateLimit(`email_change:orig:${user.id}`, 3, ms('15 minutes'));
originalCode = this.generateCode();
originalCodeExpiresAt = new Date(now.getTime() + this.ORIGINAL_CODE_TTL_MS);
originalCodeSentAt = now;
await this.emailService.sendEmailChangeOriginal(user.email!, user.username, originalCode, user.locale);
}
const originalProof = requireOriginal ? null : this.generateProof();
await this.repo.createTicket({
ticket,
user_id: user.id,
require_original: requireOriginal,
original_email: user.email,
original_verified: !requireOriginal,
original_proof: originalProof,
original_code: originalCode,
original_code_sent_at: originalCodeSentAt,
original_code_expires_at: originalCodeExpiresAt,
new_email: null,
new_code: null,
new_code_sent_at: null,
new_code_expires_at: null,
status: requireOriginal ? 'pending_original' : 'pending_new',
created_at: now,
updated_at: now,
});
return {
ticket,
require_original: requireOriginal,
original_email: user.email ?? null,
original_proof: originalProof,
original_code_expires_at: originalCodeExpiresAt ? originalCodeExpiresAt.toISOString() : null,
resend_available_at: requireOriginal ? new Date(now.getTime() + this.RESEND_COOLDOWN_MS).toISOString() : null,
};
}
async resendOriginal(user: User, ticket: string): Promise<void> {
const row = await this.getTicketForUser(ticket, user.id);
if (!row.require_original || row.original_verified) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_ALREADY_VERIFIED);
}
if (!row.original_email) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.NO_ORIGINAL_EMAIL_ON_RECORD);
}
this.assertCooldown(row.original_code_sent_at);
await this.ensureRateLimit(`email_change:orig:${user.id}`, 3, ms('15 minutes'));
const now = new Date();
const originalCode = this.generateCode();
const originalCodeExpiresAt = new Date(now.getTime() + this.ORIGINAL_CODE_TTL_MS);
await this.emailService.sendEmailChangeOriginal(row.original_email, user.username, originalCode, user.locale);
row.original_code = originalCode;
row.original_code_sent_at = now;
row.original_code_expires_at = originalCodeExpiresAt;
row.updated_at = now;
await this.repo.updateTicket(row);
}
async verifyOriginal(user: User, ticket: string, code: string): Promise<VerifyOriginalResult> {
const row = await this.getTicketForUser(ticket, user.id);
if (!row.require_original) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_VERIFICATION_NOT_REQUIRED);
}
if (row.original_verified && row.original_proof) {
return {original_proof: row.original_proof};
}
if (!row.original_code || !row.original_code_expires_at) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_NOT_ISSUED);
}
if (row.original_code_expires_at.getTime() < Date.now()) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_EXPIRED);
}
if (row.original_code !== code.trim()) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_VERIFICATION_CODE);
}
const now = new Date();
const originalProof = this.generateProof();
row.original_verified = true;
row.original_proof = originalProof;
row.status = 'pending_new';
row.updated_at = now;
await this.repo.updateTicket(row);
return {original_proof: originalProof};
}
async requestNewEmail(
user: User,
ticket: string,
newEmail: string,
originalProof: string,
): Promise<RequestNewEmailResult> {
const row = await this.getTicketForUser(ticket, user.id);
if (!row.original_verified || !row.original_proof) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST);
}
if (row.original_proof !== originalProof) {
throw InputValidationError.fromCode('original_proof', ValidationErrorCodes.INVALID_PROOF_TOKEN);
}
const trimmedEmail = newEmail.trim();
if (!trimmedEmail) {
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.EMAIL_IS_REQUIRED);
}
if (row.original_email && trimmedEmail.toLowerCase() === row.original_email.toLowerCase()) {
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.NEW_EMAIL_MUST_BE_DIFFERENT);
}
const existing = await this.userAccountRepository.findByEmail(trimmedEmail.toLowerCase());
if (existing && existing.id !== user.id) {
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE);
}
this.assertCooldown(row.new_code_sent_at);
await this.ensureRateLimit(`email_change:new:${user.id}`, 5, ms('15 minutes'));
const now = new Date();
const newCode = this.generateCode();
const newCodeExpiresAt = new Date(now.getTime() + this.NEW_CODE_TTL_MS);
await this.emailService.sendEmailChangeNew(trimmedEmail, user.username, newCode, user.locale);
row.new_email = trimmedEmail;
row.new_code = newCode;
row.new_code_sent_at = now;
row.new_code_expires_at = newCodeExpiresAt;
row.status = 'pending_new';
row.updated_at = now;
await this.repo.updateTicket(row);
return {
ticket,
new_email: trimmedEmail,
new_code_expires_at: newCodeExpiresAt.toISOString(),
resend_available_at: new Date(now.getTime() + this.RESEND_COOLDOWN_MS).toISOString(),
};
}
async resendNew(user: User, ticket: string): Promise<void> {
const row = await this.getTicketForUser(ticket, user.id);
if (!row.new_email) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.NO_NEW_EMAIL_REQUESTED);
}
this.assertCooldown(row.new_code_sent_at);
await this.ensureRateLimit(`email_change:new:${user.id}`, 5, ms('15 minutes'));
const now = new Date();
const newCode = this.generateCode();
const newCodeExpiresAt = new Date(now.getTime() + this.NEW_CODE_TTL_MS);
await this.emailService.sendEmailChangeNew(row.new_email, user.username, newCode, user.locale);
row.new_code = newCode;
row.new_code_sent_at = now;
row.new_code_expires_at = newCodeExpiresAt;
row.updated_at = now;
await this.repo.updateTicket(row);
}
async verifyNew(user: User, ticket: string, code: string, originalProof: string): Promise<string> {
const row = await this.getTicketForUser(ticket, user.id);
if (!row.original_verified || !row.original_proof) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST);
}
if (row.original_proof !== originalProof) {
throw InputValidationError.fromCode('original_proof', ValidationErrorCodes.INVALID_PROOF_TOKEN);
}
if (!row.new_email || !row.new_code || !row.new_code_expires_at) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_NOT_ISSUED);
}
if (row.new_code_expires_at.getTime() < Date.now()) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_EXPIRED);
}
if (row.new_code !== code.trim()) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_VERIFICATION_CODE);
}
const now = new Date();
const token = this.generateToken();
const expiresAt = new Date(now.getTime() + this.TOKEN_TTL_MS);
await this.repo.createToken({
token_: token,
user_id: user.id,
new_email: row.new_email,
expires_at: expiresAt,
created_at: now,
});
row.status = 'completed';
row.updated_at = now;
await this.repo.updateTicket(row);
return token;
}
async consumeToken(userId: bigint, token: string): Promise<string> {
const row = await this.repo.findToken(token);
if (!row || row.user_id !== userId) {
throw InputValidationError.fromCode('email_token', ValidationErrorCodes.INVALID_EMAIL_TOKEN);
}
if (row.expires_at.getTime() < Date.now()) {
await this.repo.deleteToken(token);
throw InputValidationError.fromCode('email_token', ValidationErrorCodes.EMAIL_TOKEN_EXPIRED);
}
await this.repo.deleteToken(token);
return row.new_email;
}
async requestBouncedNewEmail(user: User, newEmail: string): Promise<RequestNewEmailResult> {
this.ensureBouncedEmailRecoveryAllowed(user);
const startResult = await this.start(user);
if (startResult.require_original || !startResult.original_proof) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST);
}
return await this.requestNewEmail(user, startResult.ticket, newEmail, startResult.original_proof);
}
async resendBouncedNew(user: User, ticket: string): Promise<void> {
this.ensureBouncedEmailRecoveryAllowed(user);
await this.resendNew(user, ticket);
}
async verifyBouncedNew(user: User, ticket: string, code: string): Promise<User> {
this.ensureBouncedEmailRecoveryAllowed(user);
const row = await this.getTicketForUser(ticket, user.id);
if (row.require_original || !row.original_proof) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST);
}
const emailToken = await this.verifyNew(user, ticket, code, row.original_proof);
const updatedEmail = await this.consumeToken(user.id, emailToken);
const updates: {
email: string;
email_verified: boolean;
email_bounced: boolean;
suspicious_activity_flags?: number;
} = {
email: updatedEmail,
email_verified: true,
email_bounced: false,
};
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
const newFlags = user.suspiciousActivityFlags & ~EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS;
if (newFlags !== user.suspiciousActivityFlags) {
updates.suspicious_activity_flags = newFlags;
}
}
return await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
}
private async getTicketForUser(ticket: string, userId: bigint) {
const row = await this.repo.findTicket(ticket);
if (!row || row.user_id !== userId) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_TICKET);
}
if (row.status === 'completed') {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.TICKET_ALREADY_COMPLETED);
}
return row;
}
private ensureBouncedEmailRecoveryAllowed(user: User): void {
if (!user.emailBounced) {
throw new AccessDeniedError();
}
if (!user.email) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.MUST_HAVE_EMAIL_TO_CHANGE_IT);
}
}
private generateCode(): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let raw = '';
while (raw.length < 8) {
const byte = crypto.randomBytes(1)[0];
const idx = byte % alphabet.length;
raw += alphabet[idx];
}
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}`;
}
private generateTicket(): string {
return crypto.randomUUID();
}
private generateToken(): string {
return crypto.randomUUID();
}
private generateProof(): string {
return crypto.randomUUID();
}
private assertCooldown(sentAt: Date | null | undefined) {
if (!sentAt) return;
const nextAllowed = sentAt.getTime() + this.RESEND_COOLDOWN_MS;
if (nextAllowed > Date.now()) {
const retryAfter = Math.ceil((nextAllowed - Date.now()) / 1000);
throw new RateLimitError({
message: 'Please wait before resending.',
retryAfter,
limit: 1,
resetTime: new Date(nextAllowed),
});
}
}
private async ensureRateLimit(identifier: string, maxAttempts: number, windowMs: number) {
const result = await this.rateLimitService.checkLimit({identifier, maxAttempts, windowMs});
if (!result.allowed) {
throw new RateLimitError({
message: 'Too many attempts. Please try again later.',
retryAfter: result.retryAfter || 0,
limit: result.limit,
resetTime: result.resetTime,
});
}
}
}

View File

@@ -0,0 +1,228 @@
/*
* 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 crypto from 'node:crypto';
import type {User} from '@fluxer/api/src/models/User';
import type {PasswordChangeRepository} from '@fluxer/api/src/user/repositories/auth/PasswordChangeRepository';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import {ms} from 'itty-time';
export interface IAuthServiceForPasswordChange {
hashPassword(password: string): Promise<string>;
isPasswordPwned(password: string): Promise<boolean>;
}
export interface StartPasswordChangeResult {
ticket: string;
code_expires_at: string;
resend_available_at: string;
}
export interface VerifyPasswordChangeResult {
verification_proof: string;
}
export class PasswordChangeService {
private readonly CODE_TTL_MS = ms('10 minutes');
private readonly RESEND_COOLDOWN_MS = ms('30 seconds');
constructor(
private readonly repo: PasswordChangeRepository,
private readonly emailService: IEmailService,
private readonly authService: IAuthServiceForPasswordChange,
private readonly userAccountRepository: IUserAccountRepository,
private readonly rateLimitService: IRateLimitService,
) {}
async start(user: User): Promise<StartPasswordChangeResult> {
if (!user.email) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.MUST_HAVE_EMAIL_TO_CHANGE_IT);
}
await this.ensureRateLimit(`password_change:start:${user.id}`, 3, ms('15 minutes'));
const ticket = this.generateTicket();
const now = new Date();
const code = this.generateCode();
const codeExpiresAt = new Date(now.getTime() + this.CODE_TTL_MS);
await this.emailService.sendPasswordChangeVerification(user.email, user.username, code, user.locale);
await this.repo.createTicket({
ticket,
user_id: user.id,
code,
code_sent_at: now,
code_expires_at: codeExpiresAt,
verified: false,
verification_proof: null,
status: 'pending',
created_at: now,
updated_at: now,
});
return {
ticket,
code_expires_at: codeExpiresAt.toISOString(),
resend_available_at: new Date(now.getTime() + this.RESEND_COOLDOWN_MS).toISOString(),
};
}
async resend(user: User, ticket: string): Promise<void> {
const row = await this.getTicketForUser(ticket, user.id);
if (!user.email) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.MUST_HAVE_EMAIL_TO_CHANGE_IT);
}
this.assertCooldown(row.code_sent_at);
await this.ensureRateLimit(`password_change:resend:${user.id}`, 3, ms('15 minutes'));
const now = new Date();
const code = this.generateCode();
const codeExpiresAt = new Date(now.getTime() + this.CODE_TTL_MS);
await this.emailService.sendPasswordChangeVerification(user.email, user.username, code, user.locale);
row.code = code;
row.code_sent_at = now;
row.code_expires_at = codeExpiresAt;
row.updated_at = now;
await this.repo.updateTicket(row);
}
async verify(user: User, ticket: string, code: string): Promise<VerifyPasswordChangeResult> {
const row = await this.getTicketForUser(ticket, user.id);
if (row.verified && row.verification_proof) {
return {verification_proof: row.verification_proof};
}
if (!row.code || !row.code_expires_at) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_NOT_ISSUED);
}
if (row.code_expires_at.getTime() < Date.now()) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_EXPIRED);
}
if (row.code !== code.trim()) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_VERIFICATION_CODE);
}
const now = new Date();
const verificationProof = this.generateProof();
row.verified = true;
row.verification_proof = verificationProof;
row.status = 'verified';
row.updated_at = now;
await this.repo.updateTicket(row);
return {verification_proof: verificationProof};
}
async complete(user: User, ticket: string, verificationProof: string, newPassword: string): Promise<void> {
const row = await this.getTicketForUser(ticket, user.id);
if (!row.verified || !row.verification_proof) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_TICKET);
}
if (row.verification_proof !== verificationProof) {
throw InputValidationError.fromCode('verification_proof', ValidationErrorCodes.INVALID_PROOF_TOKEN);
}
if (await this.authService.isPasswordPwned(newPassword)) {
throw InputValidationError.fromCode('new_password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
}
const newPasswordHash = await this.authService.hashPassword(newPassword);
await this.userAccountRepository.patchUpsert(user.id, {
password_hash: newPasswordHash,
password_last_changed_at: new Date(),
});
const now = new Date();
row.status = 'completed';
row.updated_at = now;
await this.repo.updateTicket(row);
}
private async getTicketForUser(ticket: string, userId: bigint) {
const row = await this.repo.findTicket(ticket);
if (!row || row.user_id !== userId) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_TICKET);
}
if (row.status === 'completed') {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.TICKET_ALREADY_COMPLETED);
}
return row;
}
private generateCode(): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let raw = '';
while (raw.length < 8) {
const byte = crypto.randomBytes(1)[0];
const idx = byte % alphabet.length;
raw += alphabet[idx];
}
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}`;
}
private generateTicket(): string {
return crypto.randomUUID();
}
private generateProof(): string {
return crypto.randomUUID();
}
private assertCooldown(sentAt: Date | null | undefined) {
if (!sentAt) return;
const nextAllowed = sentAt.getTime() + this.RESEND_COOLDOWN_MS;
if (nextAllowed > Date.now()) {
const retryAfter = Math.ceil((nextAllowed - Date.now()) / 1000);
throw new RateLimitError({
message: 'Please wait before resending.',
retryAfter,
limit: 1,
resetTime: new Date(nextAllowed),
});
}
}
private async ensureRateLimit(identifier: string, maxAttempts: number, windowMs: number) {
const result = await this.rateLimitService.checkLimit({identifier, maxAttempts, windowMs});
if (!result.allowed) {
throw new RateLimitError({
message: 'Too many attempts. Please try again later.',
retryAfter: result.retryAfter || 0,
limit: result.limit,
resetTime: result.resetTime,
});
}
}
}

View File

@@ -0,0 +1,116 @@
/*
* 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 type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAccountDeletionQueueService';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
import {hasPartialUserFieldsChanged} from '@fluxer/api/src/user/UserMappers';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import {UserOwnsGuildsError} from '@fluxer/errors/src/domains/guild/UserOwnsGuildsError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {ms} from 'itty-time';
interface UserAccountLifecycleServiceDeps {
userAccountRepository: IUserAccountRepository;
guildRepository: IGuildRepositoryAggregate;
authService: AuthService;
emailService: IEmailService;
updatePropagator: UserAccountUpdatePropagator;
kvDeletionQueue: KVAccountDeletionQueueService;
}
export class UserAccountLifecycleService {
constructor(private readonly deps: UserAccountLifecycleServiceDeps) {}
async selfDisable(userId: UserID): Promise<void> {
const user = await this.deps.userAccountRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const updatedUser = await this.deps.userAccountRepository.patchUpsert(
userId,
{
flags: user.flags | UserFlags.DISABLED,
},
user.toRow(),
);
await this.deps.authService.terminateAllUserSessions(userId);
if (updatedUser) {
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.deps.updatePropagator.updateUserCache(updatedUser);
}
}
}
async selfDelete(userId: UserID): Promise<void> {
const user = await this.deps.userAccountRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const ownedGuildIds = await this.deps.guildRepository.listOwnedGuildIds(userId);
if (ownedGuildIds.length > 0) {
throw new UserOwnsGuildsError();
}
const gracePeriodMs = Config.deletionGracePeriodHours * ms('1 hour');
const pendingDeletionAt = new Date(Date.now() + gracePeriodMs);
const updatedUser = await this.deps.userAccountRepository.patchUpsert(
userId,
{
flags: user.flags | UserFlags.SELF_DELETED,
pending_deletion_at: pendingDeletionAt,
},
user.toRow(),
);
await this.deps.userAccountRepository.addPendingDeletion(userId, pendingDeletionAt, DeletionReasons.USER_REQUESTED);
await this.deps.kvDeletionQueue.scheduleDeletion(userId, pendingDeletionAt, DeletionReasons.USER_REQUESTED);
if (user.email) {
await this.deps.emailService.sendSelfDeletionScheduledEmail(
user.email,
user.username,
pendingDeletionAt,
user.locale,
);
}
await this.deps.authService.terminateAllUserSessions(userId);
if (updatedUser) {
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.deps.updatePropagator.updateUserCache(updatedUser);
}
}
}
}

View File

@@ -0,0 +1,310 @@
/*
* 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 type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {IConnectionRepository} from '@fluxer/api/src/connection/IConnectionRepository';
import type {UserConnectionRow} from '@fluxer/api/src/database/types/ConnectionTypes';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {ConnectionVisibilityFlags} from '@fluxer/constants/src/ConnectionConstants';
import {RelationshipTypes, UserFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
interface UserAccountLookupServiceDeps {
userAccountRepository: IUserAccountRepository;
userChannelRepository: IUserChannelRepository;
userRelationshipRepository: IUserRelationshipRepository;
guildRepository: IGuildRepositoryAggregate;
guildService: GuildService;
discriminatorService: IDiscriminatorService;
connectionRepository: IConnectionRepository;
}
export class UserAccountLookupService {
constructor(private readonly deps: UserAccountLookupServiceDeps) {}
async findUnique(userId: UserID): Promise<User | null> {
return await this.deps.userAccountRepository.findUnique(userId);
}
async findUniqueAssert(userId: UserID): Promise<User> {
return await this.deps.userAccountRepository.findUniqueAssert(userId);
}
async getUserProfile(params: {
userId: UserID;
targetId: UserID;
guildId?: GuildID;
withMutualFriends?: boolean;
withMutualGuilds?: boolean;
requestCache: RequestCache;
}): Promise<{
user: User;
guildMember?: GuildMemberResponse | null;
guildMemberDomain?: GuildMember | null;
premiumType?: number;
premiumSince?: Date;
premiumLifetimeSequence?: number;
mutualFriends?: Array<User>;
mutualGuilds?: Array<{id: string; nick: string | null}>;
connections?: Array<UserConnectionRow>;
}> {
const {userId, targetId, guildId, withMutualFriends, withMutualGuilds, requestCache} = params;
const user = await this.deps.userAccountRepository.findUnique(targetId);
if (!user) throw new UnknownUserError();
if (userId !== targetId) {
await this.validateProfileAccess(userId, targetId, user);
}
let guildMember: GuildMemberResponse | null = null;
let guildMemberDomain: GuildMember | null = null;
if (guildId != null) {
guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId);
if (guildMemberDomain) {
guildMember = await this.deps.guildService.getMember({
userId,
targetId,
guildId,
requestCache,
});
}
}
let premiumType = user.premiumType ?? undefined;
let premiumSince = user.premiumSince ?? undefined;
let premiumLifetimeSequence = user.premiumLifetimeSequence ?? undefined;
if (user.flags & UserFlags.PREMIUM_BADGE_HIDDEN) {
premiumType = undefined;
premiumSince = undefined;
premiumLifetimeSequence = undefined;
} else {
if (user.premiumType === UserPremiumTypes.LIFETIME) {
if (user.flags & UserFlags.PREMIUM_BADGE_MASKED) {
premiumType = UserPremiumTypes.SUBSCRIPTION;
}
if (user.flags & UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN) {
premiumLifetimeSequence = undefined;
}
}
if (user.flags & UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN) {
premiumSince = undefined;
}
}
const [mutualFriends, mutualGuilds, connections] = await Promise.all([
withMutualFriends && userId !== targetId ? this.getMutualFriends(userId, targetId) : undefined,
withMutualGuilds && userId !== targetId ? this.getMutualGuilds(userId, targetId) : undefined,
this.getVisibleConnections(userId, targetId),
]);
return {
user,
guildMember,
guildMemberDomain,
premiumType,
premiumSince,
premiumLifetimeSequence,
mutualFriends,
mutualGuilds,
connections,
};
}
private async validateProfileAccess(userId: UserID, targetId: UserID, targetUser: User): Promise<void> {
if (targetUser.isBot) {
return;
}
const friendship = await this.deps.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.FRIEND,
);
if (friendship) {
return;
}
const incomingRequest = await this.deps.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.INCOMING_REQUEST,
);
if (incomingRequest) {
return;
}
const [userGuildIds, targetGuildIds] = await Promise.all([
this.deps.userAccountRepository.getUserGuildIds(userId),
this.deps.userAccountRepository.getUserGuildIds(targetId),
]);
const userGuildIdSet = new Set(userGuildIds.map((id) => id.toString()));
const hasMutualGuild = targetGuildIds.some((id) => userGuildIdSet.has(id.toString()));
if (hasMutualGuild) {
return;
}
if (await this.hasSharedGroupDm(userId, targetId)) {
return;
}
throw new MissingAccessError();
}
private async hasSharedGroupDm(userId: UserID, targetId: UserID): Promise<boolean> {
const privateChannels = await this.deps.userChannelRepository.listPrivateChannels(userId);
return privateChannels.some(
(channel) => channel.type === ChannelTypes.GROUP_DM && channel.recipientIds.has(targetId),
);
}
private async getMutualFriends(userId: UserID, targetId: UserID): Promise<Array<User>> {
const [userRelationships, targetRelationships] = await Promise.all([
this.deps.userRelationshipRepository.listRelationships(userId),
this.deps.userRelationshipRepository.listRelationships(targetId),
]);
const userFriendIds = new Set(
userRelationships
.filter((rel) => rel.type === RelationshipTypes.FRIEND)
.map((rel) => rel.targetUserId.toString()),
);
const mutualFriendIds = targetRelationships
.filter((rel) => rel.type === RelationshipTypes.FRIEND && userFriendIds.has(rel.targetUserId.toString()))
.map((rel) => rel.targetUserId);
if (mutualFriendIds.length === 0) {
return [];
}
const users = await this.deps.userAccountRepository.listUsers(mutualFriendIds);
return users.sort((a, b) => this.compareUsersByIdDesc(a, b));
}
private compareUsersByIdDesc(a: User, b: User): number {
if (b.id > a.id) return 1;
if (b.id < a.id) return -1;
return 0;
}
private async getMutualGuilds(userId: UserID, targetId: UserID): Promise<Array<{id: string; nick: string | null}>> {
const [userGuildIds, targetGuildIds] = await Promise.all([
this.deps.userAccountRepository.getUserGuildIds(userId),
this.deps.userAccountRepository.getUserGuildIds(targetId),
]);
const userGuildIdSet = new Set(userGuildIds.map((id) => id.toString()));
const mutualGuildIds = targetGuildIds.filter((id) => userGuildIdSet.has(id.toString()));
if (mutualGuildIds.length === 0) {
return [];
}
const memberPromises = mutualGuildIds.map((guildId) => this.deps.guildRepository.getMember(guildId, targetId));
const members = await Promise.all(memberPromises);
return mutualGuildIds.map((guildId, index) => ({
id: guildId.toString(),
nick: members[index]?.nickname ?? null,
}));
}
async generateUniqueDiscriminator(username: string): Promise<number> {
const usedDiscriminators = await this.deps.userAccountRepository.findDiscriminatorsByUsername(username);
for (let i = 1; i <= 9999; i++) {
if (!usedDiscriminators.has(i)) return i;
}
throw new Error('No available discriminators for this username');
}
async checkUsernameDiscriminatorAvailability(params: {username: string; discriminator: number}): Promise<boolean> {
const {username, discriminator} = params;
const isAvailable = await this.deps.discriminatorService.isDiscriminatorAvailableForUsername(
username,
discriminator,
);
return !isAvailable;
}
private async getVisibleConnections(viewerId: UserID, targetId: UserID): Promise<Array<UserConnectionRow>> {
const connections = await this.deps.connectionRepository.findByUserId(targetId);
const verified = connections.filter((connection) => connection.verified);
if (viewerId === targetId) {
return verified;
}
const [isFriend, hasMutualGuild] = await Promise.all([
this.areFriends(viewerId, targetId),
this.haveMutualGuild(viewerId, targetId),
]);
return verified.filter((connection) => {
const flags = connection.visibility_flags;
if (flags & ConnectionVisibilityFlags.EVERYONE) {
return true;
}
if (flags & ConnectionVisibilityFlags.FRIENDS && isFriend) {
return true;
}
if (flags & ConnectionVisibilityFlags.MUTUAL_GUILDS && hasMutualGuild) {
return true;
}
return false;
});
}
private async areFriends(userId1: UserID, userId2: UserID): Promise<boolean> {
const friendship = await this.deps.userRelationshipRepository.getRelationship(
userId1,
userId2,
RelationshipTypes.FRIEND,
);
return friendship !== null;
}
private async haveMutualGuild(userId1: UserID, userId2: UserID): Promise<boolean> {
const [user1GuildIds, user2GuildIds] = await Promise.all([
this.deps.userAccountRepository.getUserGuildIds(userId1),
this.deps.userAccountRepository.getUserGuildIds(userId2),
]);
const user1GuildIdSet = new Set(user1GuildIds.map((id) => id.toString()));
return user2GuildIds.some((id) => user1GuildIdSet.has(id.toString()));
}
}

View File

@@ -0,0 +1,59 @@
/*
* 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 type {UserID} from '@fluxer/api/src/BrandedTypes';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
import type {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
interface UserAccountNotesServiceDeps {
userAccountRepository: IUserAccountRepository;
userRelationshipRepository: IUserRelationshipRepository;
updatePropagator: UserAccountUpdatePropagator;
}
export class UserAccountNotesService {
constructor(private readonly deps: UserAccountNotesServiceDeps) {}
async getUserNote(params: {userId: UserID; targetId: UserID}): Promise<{note: string} | null> {
const {userId, targetId} = params;
const note = await this.deps.userRelationshipRepository.getUserNote(userId, targetId);
return note ? {note: note.note} : null;
}
async getUserNotes(userId: UserID): Promise<Record<string, string>> {
const notes = await this.deps.userRelationshipRepository.getUserNotes(userId);
return Object.fromEntries(Array.from(notes.entries()).map(([k, v]) => [k.toString(), v]));
}
async setUserNote(params: {userId: UserID; targetId: UserID; note: string | null}): Promise<void> {
const {userId, targetId, note} = params;
const targetUser = await this.deps.userAccountRepository.findUnique(targetId);
if (!targetUser) throw new UnknownUserError();
if (note) {
await this.deps.userRelationshipRepository.upsertUserNote(userId, targetId, note);
} else {
await this.deps.userRelationshipRepository.clearUserNote(userId, targetId);
}
await this.deps.updatePropagator.dispatchUserNoteUpdate({userId, targetId, note: note ?? ''});
}
}

View File

@@ -0,0 +1,481 @@
/*
* 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 type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {EntityAssetService, PreparedAssetUpload} from '@fluxer/api/src/infrastructure/EntityAssetService';
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {User} from '@fluxer/api/src/models/User';
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
import {deriveDominantAvatarColor} from '@fluxer/api/src/utils/AvatarColorUtils';
import * as EmojiUtils from '@fluxer/api/src/utils/EmojiUtils';
import {MAX_BIO_LENGTH} from '@fluxer/constants/src/LimitConstants';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {UserUpdateRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import {ms} from 'itty-time';
interface UserUpdateMetadata {
invalidateAuthSessions?: boolean;
}
type UserFieldUpdates = Partial<UserRow>;
export interface ProfileUpdateResult {
updates: UserFieldUpdates;
metadata: UserUpdateMetadata;
preparedAvatarUpload: PreparedAssetUpload | null;
preparedBannerUpload: PreparedAssetUpload | null;
}
interface UserAccountProfileServiceDeps {
userAccountRepository: IUserAccountRepository;
guildRepository: IGuildRepositoryAggregate;
entityAssetService: EntityAssetService;
rateLimitService: IRateLimitService;
updatePropagator: UserAccountUpdatePropagator;
limitConfigService: LimitConfigService;
}
export class UserAccountProfileService {
constructor(private readonly deps: UserAccountProfileServiceDeps) {}
async processProfileUpdates(params: {user: User; data: UserUpdateRequest}): Promise<ProfileUpdateResult> {
const {user, data} = params;
const updates: UserFieldUpdates = {
avatar_hash: user.avatarHash,
banner_hash: user.bannerHash,
flags: user.flags,
};
const metadata: UserUpdateMetadata = {};
let preparedAvatarUpload: PreparedAssetUpload | null = null;
let preparedBannerUpload: PreparedAssetUpload | null = null;
if (data.bio !== undefined) {
await this.processBioUpdate({user, bio: data.bio, updates});
}
if (data.pronouns !== undefined) {
await this.processPronounsUpdate({user, pronouns: data.pronouns, updates});
}
if (data.accent_color !== undefined) {
await this.processAccentColorUpdate({user, accentColor: data.accent_color, updates});
}
if (data.avatar !== undefined) {
preparedAvatarUpload = await this.processAvatarUpdate({user, avatar: data.avatar, updates});
}
if (data.banner !== undefined) {
try {
preparedBannerUpload = await this.processBannerUpdate({user, banner: data.banner, updates});
} catch (error) {
if (preparedAvatarUpload) {
await this.deps.entityAssetService.rollbackAssetUpload(preparedAvatarUpload);
}
throw error;
}
}
if (!user.isBot) {
this.processPremiumBadgeFlags({user, data, updates});
this.processPremiumOnboardingDismissal({user, data, updates});
this.processGiftInventoryRead({user, data, updates});
this.processUsedMobileClient({user, data, updates});
}
return {updates, metadata, preparedAvatarUpload, preparedBannerUpload};
}
async commitAssetChanges(result: ProfileUpdateResult): Promise<void> {
if (result.preparedAvatarUpload) {
await this.deps.entityAssetService.commitAssetChange({
prepared: result.preparedAvatarUpload,
deferDeletion: true,
});
}
if (result.preparedBannerUpload) {
await this.deps.entityAssetService.commitAssetChange({
prepared: result.preparedBannerUpload,
deferDeletion: true,
});
}
}
async rollbackAssetChanges(result: ProfileUpdateResult): Promise<void> {
if (result.preparedAvatarUpload) {
await this.deps.entityAssetService.rollbackAssetUpload(result.preparedAvatarUpload);
}
if (result.preparedBannerUpload) {
await this.deps.entityAssetService.rollbackAssetUpload(result.preparedBannerUpload);
}
}
private async processBioUpdate(params: {user: User; bio: string | null; updates: UserFieldUpdates}): Promise<void> {
const {user, bio, updates} = params;
if (bio !== user.bio) {
getMetricsService().counter({name: 'fluxer.users.bio_updated'});
const bioRateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `bio_change:${user.id}`,
maxAttempts: 25,
windowMs: ms('30 minutes'),
});
if (!bioRateLimit.allowed) {
const minutes = Math.ceil((bioRateLimit.retryAfter || 0) / 60);
throw InputValidationError.fromCode('bio', ValidationErrorCodes.BIO_CHANGED_TOO_MANY_TIMES, {minutes});
}
const ctx = createLimitMatchContext({user});
const maxBioLength = resolveLimitSafe(
this.deps.limitConfigService.getConfigSnapshot(),
ctx,
'max_bio_length',
MAX_BIO_LENGTH,
);
if (bio && bio.length > maxBioLength) {
throw InputValidationError.fromCode('bio', ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH, {
maxLength: maxBioLength,
});
}
let sanitizedBio = bio;
if (bio) {
sanitizedBio = await EmojiUtils.sanitizeCustomEmojis({
content: bio,
userId: user.id,
webhookId: null,
guildId: null,
userRepository: this.deps.userAccountRepository,
guildRepository: this.deps.guildRepository,
limitConfigService: this.deps.limitConfigService,
});
}
updates.bio = sanitizedBio;
}
}
private async processPronounsUpdate(params: {
user: User;
pronouns: string | null;
updates: UserFieldUpdates;
}): Promise<void> {
const {user, pronouns, updates} = params;
if (pronouns !== user.pronouns) {
getMetricsService().counter({name: 'fluxer.users.pronouns_updated'});
const pronounsRateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `pronouns_change:${user.id}`,
maxAttempts: 25,
windowMs: ms('30 minutes'),
});
if (!pronounsRateLimit.allowed) {
const minutes = Math.ceil((pronounsRateLimit.retryAfter || 0) / 60);
throw InputValidationError.fromCode('pronouns', ValidationErrorCodes.PRONOUNS_CHANGED_TOO_MANY_TIMES, {
minutes,
});
}
updates.pronouns = pronouns;
}
}
private async processAccentColorUpdate(params: {
user: User;
accentColor: number | null;
updates: UserFieldUpdates;
}): Promise<void> {
const {user, accentColor, updates} = params;
if (accentColor !== user.accentColor) {
getMetricsService().counter({name: 'fluxer.users.accent_color_updated'});
const accentColorRateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `accent_color_change:${user.id}`,
maxAttempts: 25,
windowMs: ms('30 minutes'),
});
if (!accentColorRateLimit.allowed) {
const minutes = Math.ceil((accentColorRateLimit.retryAfter || 0) / 60);
throw InputValidationError.fromCode('accent_color', ValidationErrorCodes.ACCENT_COLOR_CHANGED_TOO_MANY_TIMES, {
minutes,
});
}
updates.accent_color = accentColor;
}
}
private async processAvatarUpdate(params: {
user: User;
avatar: string | null;
updates: UserFieldUpdates;
}): Promise<PreparedAssetUpload | null> {
return await withBusinessSpan(
'fluxer.user.avatar_update',
'fluxer.users.avatars_updated',
{
user_id: params.user.id.toString(),
avatar_type: params.avatar ? 'custom' : 'default',
},
async () => {
const {user, avatar, updates} = params;
if (avatar === null) {
updates.avatar_hash = null;
updates.avatar_color = null;
if (user.avatarHash) {
return await this.deps.entityAssetService.prepareAssetUpload({
assetType: 'avatar',
entityType: 'user',
entityId: user.id,
previousHash: user.avatarHash,
base64Image: null,
errorPath: 'avatar',
});
}
return null;
}
const avatarRateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `avatar_change:${user.id}`,
maxAttempts: 25,
windowMs: ms('30 minutes'),
});
if (!avatarRateLimit.allowed) {
const minutes = Math.ceil((avatarRateLimit.retryAfter || 0) / 60);
throw InputValidationError.fromCode('avatar', ValidationErrorCodes.AVATAR_CHANGED_TOO_MANY_TIMES, {minutes});
}
const prepared = await this.deps.entityAssetService.prepareAssetUpload({
assetType: 'avatar',
entityType: 'user',
entityId: user.id,
previousHash: user.avatarHash,
base64Image: avatar,
errorPath: 'avatar',
});
const ctx = createLimitMatchContext({user});
const hasAnimatedAvatar = resolveLimitSafe(
this.deps.limitConfigService.getConfigSnapshot(),
ctx,
'feature_animated_avatar',
0,
);
if (prepared.isAnimated && hasAnimatedAvatar === 0) {
await this.deps.entityAssetService.rollbackAssetUpload(prepared);
throw InputValidationError.fromCode('avatar', ValidationErrorCodes.ANIMATED_AVATARS_REQUIRE_PREMIUM);
}
if (prepared.imageBuffer) {
const derivedColor = await deriveDominantAvatarColor(prepared.imageBuffer);
if (derivedColor !== user.avatarColor) {
updates.avatar_color = derivedColor;
}
}
if (prepared.newHash !== user.avatarHash) {
updates.avatar_hash = prepared.newHash;
return prepared;
}
return null;
},
);
}
private async processBannerUpdate(params: {
user: User;
banner: string | null;
updates: UserFieldUpdates;
}): Promise<PreparedAssetUpload | null> {
const {user, banner, updates} = params;
if (banner === null) {
updates.banner_color = null;
}
getMetricsService().counter({name: 'fluxer.users.banner_updated'});
const ctx = createLimitMatchContext({user});
const hasAnimatedBanner = resolveLimitSafe(
this.deps.limitConfigService.getConfigSnapshot(),
ctx,
'feature_animated_banner',
0,
);
if (banner && hasAnimatedBanner === 0) {
throw InputValidationError.fromCode('banner', ValidationErrorCodes.BANNERS_REQUIRE_PREMIUM);
}
const bannerRateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `banner_change:${user.id}`,
maxAttempts: 25,
windowMs: ms('30 minutes'),
});
if (!bannerRateLimit.allowed) {
const minutes = Math.ceil((bannerRateLimit.retryAfter || 0) / 60);
throw InputValidationError.fromCode('banner', ValidationErrorCodes.BANNER_CHANGED_TOO_MANY_TIMES, {minutes});
}
const prepared = await this.deps.entityAssetService.prepareAssetUpload({
assetType: 'banner',
entityType: 'user',
entityId: user.id,
previousHash: user.bannerHash,
base64Image: banner,
errorPath: 'banner',
});
if (prepared.isAnimated && hasAnimatedBanner === 0) {
await this.deps.entityAssetService.rollbackAssetUpload(prepared);
throw InputValidationError.fromCode('banner', ValidationErrorCodes.ANIMATED_AVATARS_REQUIRE_PREMIUM);
}
if (banner !== null && prepared.imageBuffer) {
const derivedColor = await deriveDominantAvatarColor(prepared.imageBuffer);
if (derivedColor !== user.bannerColor) {
updates.banner_color = derivedColor;
}
}
if (prepared.newHash !== user.bannerHash) {
updates.banner_hash = prepared.newHash;
return prepared;
}
return null;
}
private processPremiumBadgeFlags(params: {user: User; data: UserUpdateRequest; updates: UserFieldUpdates}): void {
const {user, data, updates} = params;
let flagsUpdated = false;
let newFlags = user.flags;
if (data.premium_badge_hidden !== undefined) {
if (data.premium_badge_hidden) {
newFlags = newFlags | UserFlags.PREMIUM_BADGE_HIDDEN;
} else {
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_HIDDEN;
}
flagsUpdated = true;
}
if (data.premium_badge_masked !== undefined) {
if (data.premium_badge_masked) {
newFlags = newFlags | UserFlags.PREMIUM_BADGE_MASKED;
} else {
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_MASKED;
}
flagsUpdated = true;
}
if (data.premium_badge_timestamp_hidden !== undefined) {
if (data.premium_badge_timestamp_hidden) {
newFlags = newFlags | UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN;
} else {
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN;
}
flagsUpdated = true;
}
if (data.premium_badge_sequence_hidden !== undefined) {
if (data.premium_badge_sequence_hidden) {
newFlags = newFlags | UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN;
} else {
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN;
}
flagsUpdated = true;
}
if (data.premium_enabled_override !== undefined) {
if (!(user.flags & UserFlags.STAFF)) {
throw new MissingAccessError();
}
if (data.premium_enabled_override) {
newFlags = newFlags | UserFlags.PREMIUM_ENABLED_OVERRIDE;
} else {
newFlags = newFlags & ~UserFlags.PREMIUM_ENABLED_OVERRIDE;
}
flagsUpdated = true;
}
if (flagsUpdated) {
updates.flags = newFlags;
}
}
private processPremiumOnboardingDismissal(params: {
user: User;
data: UserUpdateRequest;
updates: UserFieldUpdates;
}): void {
const {data, updates} = params;
if (data.has_dismissed_premium_onboarding !== undefined) {
if (data.has_dismissed_premium_onboarding) {
updates.premium_onboarding_dismissed_at = new Date();
}
}
}
private processGiftInventoryRead(params: {user: User; data: UserUpdateRequest; updates: UserFieldUpdates}): void {
const {user, data, updates} = params;
if (data.has_unread_gift_inventory === false) {
updates.gift_inventory_client_seq = user.giftInventoryServerSeq;
}
}
private processUsedMobileClient(params: {user: User; data: UserUpdateRequest; updates: UserFieldUpdates}): void {
const {user, data, updates} = params;
if (data.used_mobile_client !== undefined) {
let newFlags = updates.flags ?? user.flags;
if (data.used_mobile_client) {
newFlags = newFlags | UserFlags.USED_MOBILE_CLIENT;
} else {
newFlags = newFlags & ~UserFlags.USED_MOBILE_CLIENT;
}
updates.flags = newFlags;
}
}
}

View File

@@ -0,0 +1,324 @@
/*
* 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 type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {AuthMfaService} from '@fluxer/api/src/auth/services/AuthMfaService';
import {requireSudoMode, type SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
import {createChannelID, createGuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
import type {UserConnectionRow} from '@fluxer/api/src/database/types/ConnectionTypes';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import {Logger} from '@fluxer/api/src/Logger';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {User} from '@fluxer/api/src/models/User';
import type {HonoEnv} from '@fluxer/api/src/types/HonoEnv';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import type {EmailChangeService} from '@fluxer/api/src/user/services/EmailChangeService';
import type {UserService} from '@fluxer/api/src/user/services/UserService';
import {mapUserToPartialResponseWithCache} from '@fluxer/api/src/user/UserCacheHelpers';
import {createPremiumClearPatch, shouldStripExpiredPremium} from '@fluxer/api/src/user/UserHelpers';
import {
mapGuildMemberToProfileResponse,
mapUserToPrivateResponse,
mapUserToProfileResponse,
} from '@fluxer/api/src/user/UserMappers';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
import {AccountSuspiciousActivityError} from '@fluxer/errors/src/domains/user/AccountSuspiciousActivityError';
import type {ConnectionResponse} from '@fluxer/schema/src/domains/connection/ConnectionSchemas';
import type {UserUpdateWithVerificationRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {UserPrivateResponse, UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {Context} from 'hono';
import type {z} from 'zod';
export type UserUpdateWithVerificationRequestData = z.infer<typeof UserUpdateWithVerificationRequest>;
type UserUpdatePayload = Omit<
UserUpdateWithVerificationRequestData,
'mfa_method' | 'mfa_code' | 'webauthn_response' | 'webauthn_challenge' | 'email_token'
>;
interface UserProfileParams {
currentUserId: UserID;
targetUserId: UserID;
guildId?: bigint;
withMutualFriends?: boolean;
withMutualGuilds?: boolean;
requestCache: RequestCache;
}
export class UserAccountRequestService {
constructor(
private readonly authService: AuthService,
private readonly authMfaService: AuthMfaService,
private readonly emailChangeService: EmailChangeService,
private readonly userService: UserService,
private readonly userRepository: IUserRepository,
private readonly userCacheService: UserCacheService,
private readonly mediaService: IMediaService,
) {}
getCurrentUserResponse(params: {
authTokenType?: 'session' | 'bearer' | 'bot' | 'admin_api_key';
oauthBearerScopes?: Set<string> | null;
user?: User;
}): UserPrivateResponse {
const tokenType = params.authTokenType;
if (tokenType === 'bearer') {
const bearerUser = params.user;
if (!bearerUser) {
throw new UnauthorizedError();
}
this.enforceUserAccess(bearerUser);
const includeEmail = params.oauthBearerScopes?.has('email') ?? false;
const response = mapUserToPrivateResponse(bearerUser);
if (!includeEmail) {
response.email = null;
}
return response;
}
const user = params.user;
if (user) {
this.enforceUserAccess(user);
return mapUserToPrivateResponse(user);
}
throw new UnauthorizedError();
}
async updateCurrentUser(params: {
ctx: Context<HonoEnv>;
user: User;
body: UserUpdateWithVerificationRequestData;
authSession: AuthSession;
}): Promise<UserPrivateResponse> {
const {ctx, user, body, authSession} = params;
const oldEmail = user.email;
const {
mfa_method: _mfaMethod,
mfa_code: _mfaCode,
webauthn_response: _webauthnResponse,
webauthn_challenge: _webauthnChallenge,
email_token: emailToken,
...userUpdateDataRest
} = body;
let userUpdateData: UserUpdatePayload = userUpdateDataRest;
if (userUpdateData.email !== undefined) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_MUST_BE_CHANGED_VIA_TOKEN);
}
const emailTokenProvided = emailToken !== undefined;
const isUnclaimed = user.isUnclaimedAccount();
if (!isUnclaimed && userUpdateData.new_password !== undefined && !userUpdateData.password) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
}
if (isUnclaimed) {
const allowed = new Set(['username', 'discriminator', 'new_password']);
const disallowedField = Object.keys(userUpdateData).find((key) => !allowed.has(key));
if (disallowedField) {
throw InputValidationError.fromCode(
disallowedField,
ValidationErrorCodes.UNCLAIMED_ACCOUNTS_CAN_ONLY_SET_EMAIL_VIA_TOKEN,
);
}
}
let emailFromToken: string | null = null;
let emailVerifiedViaToken = false;
const needsVerification = this.requiresSensitiveUserVerification(user, userUpdateData, emailTokenProvided);
let sudoResult: SudoVerificationResult | null = null;
if (needsVerification) {
sudoResult = await requireSudoMode(ctx, user, body, this.authService, this.authMfaService);
}
if (emailTokenProvided && emailToken) {
emailFromToken = await this.emailChangeService.consumeToken(user.id, emailToken);
userUpdateData = {...userUpdateData, email: emailFromToken};
emailVerifiedViaToken = true;
}
const updatedUser = await this.userService.update({
user,
oldAuthSession: authSession,
data: userUpdateData,
request: ctx.req.raw,
sudoContext: sudoResult ?? undefined,
emailVerifiedViaToken,
});
if (emailFromToken && oldEmail && updatedUser.email && oldEmail.toLowerCase() !== updatedUser.email.toLowerCase()) {
try {
await this.authService.issueEmailRevertToken(updatedUser, oldEmail, updatedUser.email);
} catch (error) {
Logger.warn({error, userId: updatedUser.id}, 'Failed to issue email revert token');
}
}
return mapUserToPrivateResponse(updatedUser);
}
async preloadMessages(params: {
userId: UserID;
channels: ReadonlyArray<bigint>;
requestCache: RequestCache;
}): Promise<Record<string, unknown>> {
const channelIds = params.channels.map((channelId) => createChannelID(channelId));
const messages = await this.userService.preloadDMMessages({
userId: params.userId,
channelIds,
});
const mappingPromises = Object.entries(messages).map(async ([channelId, message]) => {
const mappedMessage = message
? await mapMessageToResponse({
message,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
mediaService: this.mediaService,
currentUserId: params.userId,
})
: null;
return [channelId, mappedMessage] as const;
});
const mappedEntries = await Promise.all(mappingPromises);
return Object.fromEntries(mappedEntries);
}
async getUserProfile(params: UserProfileParams): Promise<UserProfileFullResponse> {
const guildId = params.guildId ? createGuildID(params.guildId) : undefined;
const profile = await this.userService.getUserProfile({
userId: params.currentUserId,
targetId: params.targetUserId,
guildId,
withMutualFriends: params.withMutualFriends,
withMutualGuilds: params.withMutualGuilds,
requestCache: params.requestCache,
});
let profileUser = profile.user;
let premiumType = profile.premiumType;
let premiumSince = profile.premiumSince;
let premiumLifetimeSequence = profile.premiumLifetimeSequence;
if (shouldStripExpiredPremium(profileUser)) {
try {
const sanitizedUser = await this.userRepository.patchUpsert(
profileUser.id,
createPremiumClearPatch(),
profileUser.toRow(),
);
if (sanitizedUser) {
profileUser = sanitizedUser;
profile.user = sanitizedUser;
premiumType = undefined;
premiumSince = undefined;
premiumLifetimeSequence = undefined;
}
} catch (error) {
Logger.warn(
{userId: profileUser.id.toString(), error},
'Failed to sanitize expired premium fields before returning profile',
);
}
}
const userProfile = mapUserToProfileResponse(profileUser);
const guildMemberProfile = mapGuildMemberToProfileResponse(profile.guildMemberDomain ?? null);
const mutualFriends = profile.mutualFriends
? await Promise.all(
profile.mutualFriends.map((user) =>
mapUserToPartialResponseWithCache({
user,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
}),
),
)
: undefined;
const connectedAccounts = profile.connections ? this.mapConnectionsToResponse(profile.connections) : undefined;
return {
user: await mapUserToPartialResponseWithCache({
user: profileUser,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
}),
user_profile: userProfile,
guild_member: profile.guildMember ?? undefined,
guild_member_profile: guildMemberProfile ?? undefined,
premium_type: premiumType,
premium_since: premiumSince?.toISOString(),
premium_lifetime_sequence: premiumLifetimeSequence,
mutual_friends: mutualFriends,
mutual_guilds: profile.mutualGuilds,
connected_accounts: connectedAccounts,
};
}
checkTagAvailability(params: {currentUser: User; username: string; discriminator: number}): boolean {
const currentUser = params.currentUser;
const discriminator = params.discriminator;
if (
params.username.toLowerCase() === currentUser.username.toLowerCase() &&
discriminator === currentUser.discriminator
) {
return false;
}
return true;
}
private enforceUserAccess(user: User): void {
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
throw new AccountSuspiciousActivityError(user.suspiciousActivityFlags);
}
}
private requiresSensitiveUserVerification(user: User, data: UserUpdatePayload, emailTokenProvided: boolean): boolean {
const isUnclaimed = user.isUnclaimedAccount();
const usernameChanged = data.username !== undefined && data.username !== user.username;
const discriminatorChanged = data.discriminator !== undefined && data.discriminator !== user.discriminator;
const emailChanged = data.email !== undefined && data.email !== user.email;
const newPasswordProvided = data.new_password !== undefined;
if (isUnclaimed) {
return usernameChanged || discriminatorChanged;
}
return usernameChanged || discriminatorChanged || emailTokenProvided || emailChanged || newPasswordProvided;
}
private mapConnectionsToResponse(connections: Array<UserConnectionRow>): Array<ConnectionResponse> {
return connections
.sort((a, b) => a.sort_order - b.sort_order)
.map((connection) => ({
id: connection.connection_id,
type: connection.connection_type,
name: connection.name,
verified: connection.verified,
visibility_flags: connection.visibility_flags,
sort_order: connection.sort_order,
}));
}
}

View File

@@ -0,0 +1,305 @@
/*
* 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 type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
import {userHasMfa} from '@fluxer/api/src/auth/services/SudoVerificationService';
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {SudoModeRequiredError} from '@fluxer/errors/src/domains/auth/SudoModeRequiredError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {UserUpdateRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import {ms} from 'itty-time';
import {uint8ArrayToBase64} from 'uint8array-extras';
interface UserUpdateMetadata {
invalidateAuthSessions?: boolean;
}
type UserFieldUpdates = Partial<UserRow>;
interface UserAccountSecurityServiceDeps {
userAccountRepository: IUserAccountRepository;
authService: AuthService;
discriminatorService: IDiscriminatorService;
rateLimitService: IRateLimitService;
limitConfigService: LimitConfigService;
}
export class UserAccountSecurityService {
constructor(private readonly deps: UserAccountSecurityServiceDeps) {}
async processSecurityUpdates(params: {
user: User;
data: UserUpdateRequest;
sudoContext?: SudoVerificationResult;
}): Promise<{updates: UserFieldUpdates; metadata: UserUpdateMetadata}> {
const {user, data, sudoContext} = params;
const updates: UserFieldUpdates = {
password_hash: user.passwordHash,
username: user.username,
discriminator: user.discriminator,
global_name: user.isBot ? null : user.globalName,
email: user.email,
};
const metadata: UserUpdateMetadata = {
invalidateAuthSessions: false,
};
const isUnclaimedAccount = user.isUnclaimedAccount();
const identityVerifiedViaSudo = sudoContext?.method === 'mfa' || sudoContext?.method === 'sudo_token';
const identityVerifiedViaPassword = sudoContext?.method === 'password';
const hasMfa = userHasMfa(user);
const rawEmail = data.email?.trim();
const normalizedEmail = rawEmail?.toLowerCase();
const hasPasswordRequiredChanges =
(data.username !== undefined && data.username !== user.username) ||
(data.discriminator !== undefined && data.discriminator !== user.discriminator) ||
(data.email !== undefined && normalizedEmail !== user.email?.toLowerCase()) ||
data.new_password !== undefined;
const requiresVerification = hasPasswordRequiredChanges && !isUnclaimedAccount;
if (requiresVerification && !identityVerifiedViaSudo && !identityVerifiedViaPassword) {
throw new SudoModeRequiredError(hasMfa);
}
if (isUnclaimedAccount && data.new_password) {
updates.password_hash = await this.hashNewPassword(data.new_password);
updates.password_last_changed_at = new Date();
metadata.invalidateAuthSessions = false;
} else if (data.new_password) {
if (!data.password) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
}
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
throw new SudoModeRequiredError(hasMfa);
}
updates.password_hash = await this.hashNewPassword(data.new_password);
updates.password_last_changed_at = new Date();
metadata.invalidateAuthSessions = true;
}
if (data.username !== undefined) {
const {newUsername, newDiscriminator} = await this.updateUsername({
user,
username: data.username,
requestedDiscriminator: data.discriminator,
});
updates.username = newUsername;
updates.discriminator = newDiscriminator;
} else if (data.discriminator !== undefined) {
updates.discriminator = await this.updateDiscriminator({user, discriminator: data.discriminator});
}
if (user.isBot) {
updates.global_name = null;
} else if (data.global_name !== undefined) {
if (data.global_name !== user.globalName) {
getMetricsService().counter({name: 'fluxer.users.display_name_updated'});
}
updates.global_name = data.global_name;
}
if (rawEmail) {
if (normalizedEmail && normalizedEmail !== user.email?.toLowerCase()) {
const existing = await this.deps.userAccountRepository.findByEmail(normalizedEmail);
if (existing && existing.id !== user.id) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE);
}
}
updates.email = rawEmail;
}
return {updates, metadata};
}
async invalidateAndRecreateSessions({
user,
oldAuthSession,
request,
}: {
user: User;
oldAuthSession: AuthSession;
request: Request;
}): Promise<void> {
await this.deps.authService.terminateAllUserSessions(user.id);
const [newToken, newAuthSession] = await this.deps.authService.createAuthSession({user, request});
const oldAuthSessionIdHash = uint8ArrayToBase64(oldAuthSession.sessionIdHash, {urlSafe: true});
await this.deps.authService.dispatchAuthSessionChange({
userId: user.id,
oldAuthSessionIdHash,
newAuthSessionIdHash: uint8ArrayToBase64(newAuthSession.sessionIdHash, {urlSafe: true}),
newToken,
});
}
private async hashNewPassword(newPassword: string): Promise<string> {
if (await this.deps.authService.isPasswordPwned(newPassword)) {
throw InputValidationError.fromCode('new_password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
}
return await this.deps.authService.hashPassword(newPassword);
}
private async updateUsername({
user,
username,
requestedDiscriminator,
}: {
user: User;
username: string;
requestedDiscriminator?: number;
}): Promise<{newUsername: string; newDiscriminator: number}> {
const normalizedRequestedDiscriminator =
requestedDiscriminator == null ? undefined : Number(requestedDiscriminator);
if (
user.username.toLowerCase() === username.toLowerCase() &&
(normalizedRequestedDiscriminator === undefined || normalizedRequestedDiscriminator === user.discriminator)
) {
return {
newUsername: username,
newDiscriminator: user.discriminator,
};
}
const rateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `username_change:${user.id}`,
maxAttempts: 5,
windowMs: ms('1 hour'),
});
if (!rateLimit.allowed) {
const minutes = Math.ceil((rateLimit.retryAfter || 0) / 60);
throw InputValidationError.fromCode('username', ValidationErrorCodes.USERNAME_CHANGED_TOO_MANY_TIMES, {
minutes,
});
}
const ctx = createLimitMatchContext({user});
const hasCustomDiscriminator = resolveLimitSafe(
this.deps.limitConfigService.getConfigSnapshot(),
ctx,
'feature_custom_discriminator',
0,
);
if (
hasCustomDiscriminator === 0 &&
user.username === username &&
(normalizedRequestedDiscriminator === undefined || normalizedRequestedDiscriminator === user.discriminator)
) {
return {
newUsername: user.username,
newDiscriminator: user.discriminator,
};
}
if (hasCustomDiscriminator === 0) {
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
username,
requestedDiscriminator: undefined,
user,
});
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
throw InputValidationError.fromCode(
'username',
ValidationErrorCodes.TOO_MANY_USERS_WITH_USERNAME_TRY_DIFFERENT,
);
}
return {
newUsername: username,
newDiscriminator: discriminatorResult.discriminator,
};
}
const discriminatorToUse = normalizedRequestedDiscriminator ?? user.discriminator;
if (discriminatorToUse === 0 && user.premiumType !== UserPremiumTypes.LIFETIME) {
throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.VISIONARY_REQUIRED_FOR_DISCRIMINATOR);
}
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
username,
requestedDiscriminator: discriminatorToUse,
user,
});
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
throw InputValidationError.fromCode(
'username',
discriminatorToUse !== undefined
? ValidationErrorCodes.TAG_ALREADY_TAKEN
: ValidationErrorCodes.TOO_MANY_USERS_WITH_USERNAME_TRY_DIFFERENT,
);
}
return {
newUsername: username,
newDiscriminator: discriminatorResult.discriminator,
};
}
private async updateDiscriminator({user, discriminator}: {user: User; discriminator: number}): Promise<number> {
const ctx = createLimitMatchContext({user});
const hasCustomDiscriminator = resolveLimitSafe(
this.deps.limitConfigService.getConfigSnapshot(),
ctx,
'feature_custom_discriminator',
0,
);
if (hasCustomDiscriminator === 0) {
throw InputValidationError.fromCode(
'discriminator',
ValidationErrorCodes.CHANGING_DISCRIMINATOR_REQUIRES_PREMIUM,
);
}
if (discriminator === 0 && user.premiumType !== UserPremiumTypes.LIFETIME) {
throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.VISIONARY_REQUIRED_FOR_DISCRIMINATOR);
}
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
username: user.username,
requestedDiscriminator: discriminator,
user,
});
if (!discriminatorResult.available) {
throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.TAG_ALREADY_TAKEN);
}
return discriminator;
}
}

View File

@@ -0,0 +1,353 @@
/*
* 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 type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {IConnectionRepository} from '@fluxer/api/src/connection/IConnectionRepository';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
import {GuildMemberSearchIndexService} from '@fluxer/api/src/guild/services/member/GuildMemberSearchIndexService';
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
import type {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAccountDeletionQueueService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import {Logger} from '@fluxer/api/src/Logger';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {User} from '@fluxer/api/src/models/User';
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
import type {PackService} from '@fluxer/api/src/pack/PackService';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
import type {IUserSettingsRepository} from '@fluxer/api/src/user/repositories/IUserSettingsRepository';
import {UserAccountLifecycleService} from '@fluxer/api/src/user/services/UserAccountLifecycleService';
import {UserAccountLookupService} from '@fluxer/api/src/user/services/UserAccountLookupService';
import {UserAccountNotesService} from '@fluxer/api/src/user/services/UserAccountNotesService';
import {UserAccountProfileService} from '@fluxer/api/src/user/services/UserAccountProfileService';
import {UserAccountSecurityService} from '@fluxer/api/src/user/services/UserAccountSecurityService';
import {UserAccountSettingsService} from '@fluxer/api/src/user/services/UserAccountSettingsService';
import {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
import {createPremiumClearPatch} from '@fluxer/api/src/user/UserHelpers';
import {hasPartialUserFieldsChanged} from '@fluxer/api/src/user/UserMappers';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {
UserGuildSettingsUpdateRequest,
UserSettingsUpdateRequest,
UserUpdateRequest,
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
interface UpdateUserParams {
user: User;
oldAuthSession: AuthSession;
data: UserUpdateRequest;
request: Request;
sudoContext?: SudoVerificationResult;
emailVerifiedViaToken?: boolean;
}
export class UserAccountService {
private readonly lookupService: UserAccountLookupService;
private readonly profileService: UserAccountProfileService;
private readonly securityService: UserAccountSecurityService;
private readonly settingsService: UserAccountSettingsService;
private readonly notesService: UserAccountNotesService;
private readonly lifecycleService: UserAccountLifecycleService;
private readonly updatePropagator: UserAccountUpdatePropagator;
private readonly guildRepository: IGuildRepositoryAggregate;
private readonly searchIndexService: GuildMemberSearchIndexService;
constructor(
private readonly userAccountRepository: IUserAccountRepository,
userSettingsRepository: IUserSettingsRepository,
userRelationshipRepository: IUserRelationshipRepository,
userChannelRepository: IUserChannelRepository,
authService: AuthService,
userCacheService: UserCacheService,
guildService: GuildService,
gatewayService: IGatewayService,
entityAssetService: EntityAssetService,
mediaService: IMediaService,
packService: PackService,
emailService: IEmailService,
rateLimitService: IRateLimitService,
guildRepository: IGuildRepositoryAggregate,
discriminatorService: IDiscriminatorService,
kvDeletionQueue: KVAccountDeletionQueueService,
private readonly contactChangeLogService: UserContactChangeLogService,
connectionRepository: IConnectionRepository,
readonly limitConfigService: LimitConfigService,
) {
this.guildRepository = guildRepository;
this.searchIndexService = new GuildMemberSearchIndexService();
this.updatePropagator = new UserAccountUpdatePropagator({
userCacheService,
gatewayService,
mediaService,
userRepository: userAccountRepository,
});
this.lookupService = new UserAccountLookupService({
userAccountRepository,
userRelationshipRepository,
userChannelRepository,
guildRepository,
guildService,
discriminatorService,
connectionRepository,
});
this.profileService = new UserAccountProfileService({
userAccountRepository,
guildRepository,
entityAssetService,
rateLimitService,
updatePropagator: this.updatePropagator,
limitConfigService,
});
this.securityService = new UserAccountSecurityService({
userAccountRepository,
authService,
discriminatorService,
rateLimitService,
limitConfigService,
});
this.settingsService = new UserAccountSettingsService({
userAccountRepository,
userSettingsRepository,
updatePropagator: this.updatePropagator,
guildRepository,
packService,
limitConfigService,
});
this.notesService = new UserAccountNotesService({
userAccountRepository,
userRelationshipRepository,
updatePropagator: this.updatePropagator,
});
this.lifecycleService = new UserAccountLifecycleService({
userAccountRepository,
guildRepository,
authService,
emailService,
updatePropagator: this.updatePropagator,
kvDeletionQueue,
});
}
async findUnique(userId: UserID): Promise<User | null> {
return this.lookupService.findUnique(userId);
}
async findUniqueAssert(userId: UserID): Promise<User> {
return this.lookupService.findUniqueAssert(userId);
}
async getUserProfile(params: {
userId: UserID;
targetId: UserID;
guildId?: GuildID;
withMutualFriends?: boolean;
withMutualGuilds?: boolean;
requestCache: RequestCache;
}) {
return this.lookupService.getUserProfile(params);
}
async generateUniqueDiscriminator(username: string): Promise<number> {
return this.lookupService.generateUniqueDiscriminator(username);
}
async checkUsernameDiscriminatorAvailability(params: {username: string; discriminator: number}): Promise<boolean> {
return this.lookupService.checkUsernameDiscriminatorAvailability(params);
}
async update(params: UpdateUserParams): Promise<User> {
const {user, oldAuthSession, data, request, sudoContext, emailVerifiedViaToken = false} = params;
const profileResult = await this.profileService.processProfileUpdates({user, data});
const securityResult = await this.securityService.processSecurityUpdates({user, data, sudoContext});
const updates = {
...securityResult.updates,
...profileResult.updates,
};
const metadata = {
...securityResult.metadata,
...profileResult.metadata,
};
const emailChanged = data.email !== undefined;
if (emailChanged) {
updates.email_verified = !!emailVerifiedViaToken;
}
let updatedUser: User;
try {
updatedUser = await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
} catch (error) {
await this.profileService.rollbackAssetChanges(profileResult);
Logger.error({error, userId: user.id}, 'User update failed, rolled back asset uploads');
throw error;
}
await this.contactChangeLogService.recordDiff({
oldUser: user,
newUser: updatedUser,
reason: 'user_requested',
actorUserId: user.id,
});
await this.profileService.commitAssetChanges(profileResult).catch((error) => {
Logger.error({error, userId: user.id}, 'Failed to commit asset changes after successful DB update');
});
await this.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.updatePropagator.updateUserCache(updatedUser);
}
const nameChanged =
user.username !== updatedUser.username ||
user.discriminator !== updatedUser.discriminator ||
user.globalName !== updatedUser.globalName;
if (nameChanged) {
void this.reindexGuildMembersForUser(updatedUser);
}
if (metadata.invalidateAuthSessions) {
await this.securityService.invalidateAndRecreateSessions({user, oldAuthSession, request});
}
return updatedUser;
}
private async reindexGuildMembersForUser(updatedUser: User): Promise<void> {
try {
const guildIds = await this.userAccountRepository.getUserGuildIds(updatedUser.id);
for (const guildId of guildIds) {
const guild = await this.guildRepository.findUnique(guildId);
if (!guild?.membersIndexedAt) {
continue;
}
const member = await this.guildRepository.getMember(guildId, updatedUser.id);
if (member) {
void this.searchIndexService.updateMember(member, updatedUser);
}
}
} catch (error) {
Logger.error({userId: updatedUser.id.toString(), error}, 'Failed to reindex guild members after user update');
}
}
async findSettings(userId: UserID): Promise<UserSettings> {
return this.settingsService.findSettings(userId);
}
async updateSettings(params: {userId: UserID; data: UserSettingsUpdateRequest}): Promise<UserSettings> {
return this.settingsService.updateSettings(params);
}
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
return this.settingsService.findGuildSettings(userId, guildId);
}
async updateGuildSettings(params: {
userId: UserID;
guildId: GuildID | null;
data: UserGuildSettingsUpdateRequest;
}): Promise<UserGuildSettings> {
return this.settingsService.updateGuildSettings(params);
}
async getUserNote(params: {userId: UserID; targetId: UserID}): Promise<{note: string} | null> {
return this.notesService.getUserNote(params);
}
async getUserNotes(userId: UserID): Promise<Record<string, string>> {
return this.notesService.getUserNotes(userId);
}
async setUserNote(params: {userId: UserID; targetId: UserID; note: string | null}): Promise<void> {
return this.notesService.setUserNote(params);
}
async selfDisable(userId: UserID): Promise<void> {
return this.lifecycleService.selfDisable(userId);
}
async selfDelete(userId: UserID): Promise<void> {
return this.lifecycleService.selfDelete(userId);
}
async resetCurrentUserPremiumState(user: User): Promise<void> {
const updates = {
...createPremiumClearPatch(),
premium_lifetime_sequence: null,
stripe_subscription_id: null,
stripe_customer_id: null,
has_ever_purchased: null,
first_refund_at: null,
gift_inventory_server_seq: null,
gift_inventory_client_seq: null,
flags: user.flags & ~UserFlags.PREMIUM_ENABLED_OVERRIDE,
};
const updatedUser = await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
await this.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.updatePropagator.updateUserCache(updatedUser);
}
}
async dispatchUserUpdate(user: User): Promise<void> {
return this.updatePropagator.dispatchUserUpdate(user);
}
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
return this.updatePropagator.dispatchUserSettingsUpdate({userId, settings});
}
async dispatchUserGuildSettingsUpdate({
userId,
settings,
}: {
userId: UserID;
settings: UserGuildSettings;
}): Promise<void> {
return this.updatePropagator.dispatchUserGuildSettingsUpdate({userId, settings});
}
async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise<void> {
return this.updatePropagator.dispatchUserNoteUpdate(params);
}
}

View File

@@ -0,0 +1,339 @@
/*
* 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 {type ChannelID, createChannelID, createGuildID, type GuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
import type {ChannelOverride, UserGuildSettingsRow} from '@fluxer/api/src/database/types/UserTypes';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
import type {PackService} from '@fluxer/api/src/pack/PackService';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserSettingsRepository} from '@fluxer/api/src/user/repositories/IUserSettingsRepository';
import {CustomStatusValidator} from '@fluxer/api/src/user/services/CustomStatusValidator';
import type {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
import {
DEFAULT_GUILD_FOLDER_ICON,
FriendSourceFlags,
GroupDmAddPermissionFlags,
IncomingCallFlags,
UNCATEGORIZED_FOLDER_ID,
UserNotificationSettings,
} from '@fluxer/constants/src/UserConstants';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {ValidationError} from '@fluxer/errors/src/ValidationError';
import type {
UserGuildSettingsUpdateRequest,
UserSettingsUpdateRequest,
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
interface UserAccountSettingsServiceDeps {
userAccountRepository: IUserAccountRepository;
userSettingsRepository: IUserSettingsRepository;
updatePropagator: UserAccountUpdatePropagator;
guildRepository: IGuildRepositoryAggregate;
packService: PackService;
limitConfigService: LimitConfigService;
}
export class UserAccountSettingsService {
private readonly customStatusValidator: CustomStatusValidator;
constructor(private readonly deps: UserAccountSettingsServiceDeps) {
this.customStatusValidator = new CustomStatusValidator(
this.deps.userAccountRepository,
this.deps.guildRepository,
this.deps.packService,
this.deps.limitConfigService,
);
}
async findSettings(userId: UserID): Promise<UserSettings> {
const userSettings = await this.deps.userSettingsRepository.findSettings(userId);
if (!userSettings) throw new UnknownUserError();
return userSettings;
}
async updateSettings(params: {userId: UserID; data: UserSettingsUpdateRequest}): Promise<UserSettings> {
const {userId, data} = params;
const currentSettings = await this.deps.userSettingsRepository.findSettings(userId);
if (!currentSettings) {
throw new UnknownUserError();
}
const updatedRowData = {...currentSettings.toRow(), user_id: userId};
const localeChanged = data.locale !== undefined && data.locale !== currentSettings.locale;
if (data.status !== undefined) updatedRowData.status = data.status;
if (data.status_resets_at !== undefined) updatedRowData.status_resets_at = data.status_resets_at;
if (data.status_resets_to !== undefined) updatedRowData.status_resets_to = data.status_resets_to;
if (data.theme !== undefined) {
if (data.theme !== currentSettings.theme) {
getMetricsService().counter({
name: 'fluxer.users.theme_changed',
dimensions: {
new_theme: data.theme,
old_theme: currentSettings.theme,
},
});
}
updatedRowData.theme = data.theme;
}
if (data.locale !== undefined) updatedRowData.locale = data.locale;
if (data.custom_status !== undefined) {
if (data.custom_status === null) {
updatedRowData.custom_status = null;
} else {
const validated = await this.customStatusValidator.validate(userId, data.custom_status);
updatedRowData.custom_status = {
text: validated.text,
expires_at: validated.expiresAt,
emoji_id: validated.emojiId,
emoji_name: validated.emojiName,
emoji_animated: validated.emojiAnimated,
};
}
}
if (data.flags !== undefined) updatedRowData.friend_source_flags = data.flags;
if (data.restricted_guilds !== undefined) {
updatedRowData.restricted_guilds = data.restricted_guilds
? new Set(data.restricted_guilds.map(createGuildID))
: null;
}
if (data.bot_restricted_guilds !== undefined) {
updatedRowData.bot_restricted_guilds = data.bot_restricted_guilds
? new Set(data.bot_restricted_guilds.map(createGuildID))
: null;
}
if (data.default_guilds_restricted !== undefined) {
updatedRowData.default_guilds_restricted = data.default_guilds_restricted;
}
if (data.bot_default_guilds_restricted !== undefined) {
updatedRowData.bot_default_guilds_restricted = data.bot_default_guilds_restricted;
}
if (data.inline_attachment_media !== undefined) {
updatedRowData.inline_attachment_media = data.inline_attachment_media;
}
if (data.inline_embed_media !== undefined) updatedRowData.inline_embed_media = data.inline_embed_media;
if (data.gif_auto_play !== undefined) updatedRowData.gif_auto_play = data.gif_auto_play;
if (data.render_embeds !== undefined) updatedRowData.render_embeds = data.render_embeds;
if (data.render_reactions !== undefined) updatedRowData.render_reactions = data.render_reactions;
if (data.animate_emoji !== undefined) updatedRowData.animate_emoji = data.animate_emoji;
if (data.animate_stickers !== undefined) updatedRowData.animate_stickers = data.animate_stickers;
if (data.render_spoilers !== undefined) updatedRowData.render_spoilers = data.render_spoilers;
if (data.message_display_compact !== undefined) {
updatedRowData.message_display_compact = data.message_display_compact;
}
if (data.friend_source_flags !== undefined) {
updatedRowData.friend_source_flags = this.normalizeFriendSourceFlags(data.friend_source_flags);
}
if (data.incoming_call_flags !== undefined) {
updatedRowData.incoming_call_flags = this.normalizeIncomingCallFlags(data.incoming_call_flags);
}
if (data.group_dm_add_permission_flags !== undefined) {
updatedRowData.group_dm_add_permission_flags = this.normalizeGroupDmAddPermissionFlags(
data.group_dm_add_permission_flags,
);
}
if (data.guild_folders !== undefined) {
const mappedFolders = data.guild_folders.map((folder) => ({
folder_id: folder.id,
name: folder.name ?? null,
color: folder.color ?? 0x000000,
flags: folder.flags ?? 0,
icon: folder.icon ?? DEFAULT_GUILD_FOLDER_ICON,
guild_ids: folder.guild_ids.map(createGuildID),
}));
const hasUncategorized = mappedFolders.some((folder) => folder.folder_id === UNCATEGORIZED_FOLDER_ID);
if (!hasUncategorized) {
mappedFolders.unshift({
folder_id: UNCATEGORIZED_FOLDER_ID,
name: null,
color: 0x000000,
flags: 0,
icon: DEFAULT_GUILD_FOLDER_ICON,
guild_ids: [],
});
}
updatedRowData.guild_folders = mappedFolders;
}
if (data.afk_timeout !== undefined) updatedRowData.afk_timeout = data.afk_timeout;
if (data.time_format !== undefined) updatedRowData.time_format = data.time_format;
if (data.developer_mode !== undefined) updatedRowData.developer_mode = data.developer_mode;
if (data.trusted_domains !== undefined) {
const domainsSet = new Set(data.trusted_domains);
if (domainsSet.has('*') && domainsSet.size > 1) {
throw ValidationError.fromField(
'trusted_domains',
'INVALID_TRUSTED_DOMAINS',
'Cannot combine wildcard (*) with specific domains',
);
}
updatedRowData.trusted_domains = domainsSet.size > 0 ? domainsSet : null;
}
if (data.default_hide_muted_channels !== undefined) {
updatedRowData.default_hide_muted_channels = data.default_hide_muted_channels;
}
await this.deps.userSettingsRepository.upsertSettings(updatedRowData);
const updatedSettings = await this.findSettings(userId);
await this.deps.updatePropagator.dispatchUserSettingsUpdate({userId, settings: updatedSettings});
if (localeChanged) {
const user = await this.deps.userAccountRepository.findUnique(userId);
if (user) {
const updatedUser = await this.deps.userAccountRepository.patchUpsert(
userId,
{locale: data.locale},
user.toRow(),
);
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
}
}
return updatedSettings;
}
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
return await this.deps.userSettingsRepository.findGuildSettings(userId, guildId);
}
async updateGuildSettings(params: {
userId: UserID;
guildId: GuildID | null;
data: UserGuildSettingsUpdateRequest;
}): Promise<UserGuildSettings> {
const {userId, guildId, data} = params;
const currentSettings = await this.deps.userSettingsRepository.findGuildSettings(userId, guildId);
const resolvedGuildId = guildId ?? createGuildID(0n);
const baseRow: UserGuildSettingsRow = currentSettings
? {
...currentSettings.toRow(),
user_id: userId,
guild_id: resolvedGuildId,
}
: {
user_id: userId,
guild_id: resolvedGuildId,
message_notifications: UserNotificationSettings.INHERIT,
muted: false,
mute_config: null,
mobile_push: false,
suppress_everyone: false,
suppress_roles: false,
hide_muted_channels: false,
channel_overrides: null,
version: 1,
};
const updatedRowData: UserGuildSettingsRow = {...baseRow};
if (data.message_notifications !== undefined) updatedRowData.message_notifications = data.message_notifications;
if (data.muted !== undefined) updatedRowData.muted = data.muted;
if (data.mute_config !== undefined) {
updatedRowData.mute_config = data.mute_config
? {
end_time: data.mute_config.end_time ?? null,
selected_time_window: data.mute_config.selected_time_window,
}
: null;
}
if (data.mobile_push !== undefined) updatedRowData.mobile_push = data.mobile_push;
if (data.suppress_everyone !== undefined) updatedRowData.suppress_everyone = data.suppress_everyone;
if (data.suppress_roles !== undefined) updatedRowData.suppress_roles = data.suppress_roles;
if (data.hide_muted_channels !== undefined) updatedRowData.hide_muted_channels = data.hide_muted_channels;
if (data.channel_overrides !== undefined) {
if (data.channel_overrides) {
const channelOverrides = new Map<ChannelID, ChannelOverride>();
for (const [channelIdStr, override] of Object.entries(data.channel_overrides)) {
const channelId = createChannelID(BigInt(channelIdStr));
channelOverrides.set(channelId, {
collapsed: override.collapsed,
message_notifications: override.message_notifications,
muted: override.muted,
mute_config: override.mute_config
? {
end_time: override.mute_config.end_time ?? null,
selected_time_window: override.mute_config.selected_time_window,
}
: null,
});
}
updatedRowData.channel_overrides = channelOverrides.size > 0 ? channelOverrides : null;
} else {
updatedRowData.channel_overrides = null;
}
}
const updatedSettings = await this.deps.userSettingsRepository.upsertGuildSettings(updatedRowData);
await this.deps.updatePropagator.dispatchUserGuildSettingsUpdate({userId, settings: updatedSettings});
return updatedSettings;
}
private normalizeFriendSourceFlags(flags: number): number {
let normalizedFlags = flags;
if ((normalizedFlags & FriendSourceFlags.NO_RELATION) === FriendSourceFlags.NO_RELATION) {
const hasMutualFriends =
(normalizedFlags & FriendSourceFlags.MUTUAL_FRIENDS) === FriendSourceFlags.MUTUAL_FRIENDS;
const hasMutualGuilds = (normalizedFlags & FriendSourceFlags.MUTUAL_GUILDS) === FriendSourceFlags.MUTUAL_GUILDS;
if (!hasMutualFriends || !hasMutualGuilds) {
normalizedFlags &= ~FriendSourceFlags.NO_RELATION;
}
}
return normalizedFlags;
}
private normalizeIncomingCallFlags(flags: number): number {
let normalizedFlags = flags;
const modifierFlags = flags & IncomingCallFlags.SILENT_EVERYONE;
if ((normalizedFlags & IncomingCallFlags.FRIENDS_ONLY) === IncomingCallFlags.FRIENDS_ONLY) {
normalizedFlags = IncomingCallFlags.FRIENDS_ONLY | modifierFlags;
}
if ((normalizedFlags & IncomingCallFlags.NOBODY) === IncomingCallFlags.NOBODY) {
normalizedFlags = IncomingCallFlags.NOBODY | modifierFlags;
}
return normalizedFlags;
}
private normalizeGroupDmAddPermissionFlags(flags: number): number {
let normalizedFlags = flags;
if ((normalizedFlags & GroupDmAddPermissionFlags.FRIENDS_ONLY) === GroupDmAddPermissionFlags.FRIENDS_ONLY) {
normalizedFlags = GroupDmAddPermissionFlags.FRIENDS_ONLY;
}
if ((normalizedFlags & GroupDmAddPermissionFlags.NOBODY) === GroupDmAddPermissionFlags.NOBODY) {
normalizedFlags = GroupDmAddPermissionFlags.NOBODY;
}
if ((normalizedFlags & GroupDmAddPermissionFlags.EVERYONE) === GroupDmAddPermissionFlags.EVERYONE) {
normalizedFlags = GroupDmAddPermissionFlags.EVERYONE;
}
return normalizedFlags;
}
}

View File

@@ -0,0 +1,76 @@
/*
* 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 type {UserID} from '@fluxer/api/src/BrandedTypes';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import {BaseUserUpdatePropagator} from '@fluxer/api/src/user/services/BaseUserUpdatePropagator';
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '@fluxer/api/src/user/UserMappers';
interface UserAccountUpdatePropagatorDeps {
userCacheService: UserCacheService;
gatewayService: IGatewayService;
mediaService: IMediaService;
userRepository: IUserAccountRepository;
}
export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator {
constructor(private readonly deps: UserAccountUpdatePropagatorDeps) {
super({
userCacheService: deps.userCacheService,
gatewayService: deps.gatewayService,
});
}
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
const guildIds = await this.deps.userRepository.getUserGuildIds(userId);
await this.deps.gatewayService.dispatchPresence({
userId,
event: 'USER_SETTINGS_UPDATE',
data: mapUserSettingsToResponse({settings, memberGuildIds: guildIds}),
});
}
async dispatchUserGuildSettingsUpdate({
userId,
settings,
}: {
userId: UserID;
settings: UserGuildSettings;
}): Promise<void> {
await this.deps.gatewayService.dispatchPresence({
userId,
event: 'USER_GUILD_SETTINGS_UPDATE',
data: mapUserGuildSettingsToResponse(settings),
});
}
async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise<void> {
const {userId, targetId, note} = params;
await this.deps.gatewayService.dispatchPresence({
userId,
event: 'USER_NOTE_UPDATE',
data: {id: targetId.toString(), note},
});
}
}

View File

@@ -0,0 +1,204 @@
/*
* 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 type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {AuthMfaService} from '@fluxer/api/src/auth/services/AuthMfaService';
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import type {UserService} from '@fluxer/api/src/user/services/UserService';
import type {
DisableTotpRequest,
EnableMfaTotpRequest,
MfaBackupCodesRequest,
MfaBackupCodesResponse,
PhoneAddRequest,
PhoneSendVerificationRequest,
PhoneVerifyRequest,
PhoneVerifyResponse,
SudoMfaMethodsResponse,
WebAuthnChallengeResponse,
WebAuthnCredentialListResponse,
WebAuthnCredentialUpdateRequest,
WebAuthnRegisterRequest,
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
interface UserAuthWithSudoRequest<T> {
user: User;
data: T;
sudoContext: SudoVerificationResult;
}
interface UserAuthRequest<T> {
user: User;
data: T;
}
interface UserAuthPhoneTokenRequest {
user: User;
data: PhoneAddRequest;
}
interface UserAuthWebAuthnUpdateRequest {
user: User;
credentialId: string;
data: WebAuthnCredentialUpdateRequest;
}
interface UserAuthWebAuthnRegisterRequest {
user: User;
data: WebAuthnRegisterRequest;
}
interface UserAuthWebAuthnDeleteRequest {
user: User;
credentialId: string;
}
export class UserAuthRequestService {
constructor(
private authService: AuthService,
private authMfaService: AuthMfaService,
private userService: UserService,
private userRepository: IUserRepository,
) {}
async enableTotp({
user,
data,
sudoContext,
}: UserAuthWithSudoRequest<EnableMfaTotpRequest>): Promise<MfaBackupCodesResponse> {
const backupCodes = await this.userService.enableMfaTotp({
user,
secret: data.secret,
code: data.code,
sudoContext,
});
return this.toBackupCodesResponse(backupCodes);
}
async disableTotp({user, data, sudoContext}: UserAuthWithSudoRequest<DisableTotpRequest>): Promise<void> {
await this.userService.disableMfaTotp({
user,
code: data.code,
sudoContext,
password: data.password,
});
}
async getBackupCodes({
user,
data,
sudoContext,
}: UserAuthWithSudoRequest<MfaBackupCodesRequest>): Promise<MfaBackupCodesResponse> {
const backupCodes = await this.userService.getMfaBackupCodes({
user,
regenerate: data.regenerate,
sudoContext,
password: data.password,
});
return this.toBackupCodesResponse(backupCodes);
}
async sendPhoneVerificationCode({user, data}: UserAuthRequest<PhoneSendVerificationRequest>): Promise<void> {
await this.authService.sendPhoneVerificationCode(data.phone, user.id);
}
async verifyPhoneCode({user, data}: UserAuthRequest<PhoneVerifyRequest>): Promise<PhoneVerifyResponse> {
const phoneToken = await this.authService.verifyPhoneCode(data.phone, data.code, user.id);
return {phone_token: phoneToken};
}
async addPhoneToAccount({user, data}: UserAuthPhoneTokenRequest): Promise<void> {
await this.authService.addPhoneToAccount(user.id, data.phone_token);
}
async removePhoneFromAccount(user: User): Promise<void> {
await this.authService.removePhoneFromAccount(user.id);
}
async enableSmsMfa(user: User): Promise<void> {
await this.authService.enableSmsMfa(user.id);
}
async disableSmsMfa(user: User): Promise<void> {
await this.authService.disableSmsMfa(user.id);
}
async forgetAuthorizedIps(user: User): Promise<void> {
await this.userRepository.deleteAllAuthorizedIps(user.id);
}
async listWebAuthnCredentials(user: User): Promise<WebAuthnCredentialListResponse> {
const credentials = await this.userRepository.listWebAuthnCredentials(user.id);
return credentials.map((cred) => ({
id: cred.credentialId,
name: cred.name,
created_at: cred.createdAt.toISOString(),
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
}));
}
async generateWebAuthnRegistrationOptions(user: User): Promise<WebAuthnChallengeResponse> {
const options = await this.authService.generateWebAuthnRegistrationOptions(user.id);
return this.toWebAuthnChallengeResponse(options);
}
async registerWebAuthnCredential({user, data}: UserAuthWebAuthnRegisterRequest): Promise<void> {
await this.authService.verifyWebAuthnRegistration(user.id, data.response, data.challenge, data.name);
}
async renameWebAuthnCredential({user, credentialId, data}: UserAuthWebAuthnUpdateRequest): Promise<void> {
await this.authService.renameWebAuthnCredential(user.id, credentialId, data.name);
}
async deleteWebAuthnCredential({user, credentialId}: UserAuthWebAuthnDeleteRequest): Promise<void> {
await this.authService.deleteWebAuthnCredential(user.id, credentialId);
}
async listSudoMfaMethods(user: User): Promise<SudoMfaMethodsResponse> {
return this.authMfaService.getAvailableMfaMethods(user.id);
}
async sendSudoSmsCode(user: User): Promise<void> {
await this.authService.sendSmsMfaCode(user.id);
}
async getSudoWebAuthnOptions(user: User): Promise<WebAuthnChallengeResponse> {
const options = await this.authMfaService.generateWebAuthnOptionsForSudo(user.id);
return this.toWebAuthnChallengeResponse(options);
}
private toWebAuthnChallengeResponse(options: {challenge: string}): WebAuthnChallengeResponse {
const response: Record<string, unknown> & {challenge: string} = {
...options,
challenge: options.challenge,
};
return response;
}
private toBackupCodesResponse(backupCodes: Array<{code: string; consumed: boolean}>): MfaBackupCodesResponse {
return {
backup_codes: backupCodes.map((code) => ({
code: code.code,
consumed: code.consumed,
})),
};
}
}

View File

@@ -0,0 +1,207 @@
/*
* 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 type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
import {userHasMfa} from '@fluxer/api/src/auth/services/SudoVerificationService';
import {createEmailVerificationToken} from '@fluxer/api/src/BrandedTypes';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
import type {User} from '@fluxer/api/src/models/User';
import type {BotMfaMirrorService} from '@fluxer/api/src/oauth/BotMfaMirrorService';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserAuthRepository} from '@fluxer/api/src/user/repositories/IUserAuthRepository';
import {mapUserToPrivateResponse} from '@fluxer/api/src/user/UserMappers';
import * as RandomUtils from '@fluxer/api/src/utils/RandomUtils';
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import {MfaNotDisabledError} from '@fluxer/errors/src/domains/auth/MfaNotDisabledError';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import {SudoModeRequiredError} from '@fluxer/errors/src/domains/auth/SudoModeRequiredError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
export class UserAuthService {
constructor(
private userAccountRepository: IUserAccountRepository,
private userAuthRepository: IUserAuthRepository,
private authService: AuthService,
private emailService: IEmailService,
private gatewayService: IGatewayService,
private botMfaMirrorService?: BotMfaMirrorService,
) {}
async enableMfaTotp(params: {
user: User;
secret: string;
code: string;
sudoContext: SudoVerificationResult;
}): Promise<Array<MfaBackupCode>> {
const {user, secret, code, sudoContext} = params;
const identityVerifiedViaSudo = sudoContext.method === 'mfa' || sudoContext.method === 'sudo_token';
const identityVerifiedViaPassword = sudoContext.method === 'password';
const hasMfa = userHasMfa(user);
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
throw new SudoModeRequiredError(hasMfa);
}
if (user.totpSecret) throw new MfaNotDisabledError();
const userId = user.id;
if (!(await this.authService.verifyMfaCode({userId: user.id, mfaSecret: secret, code}))) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
authenticatorTypes.add(UserAuthenticatorTypes.TOTP);
const updatedUser = await this.userAccountRepository.patchUpsert(
userId,
{
totp_secret: secret,
authenticator_types: authenticatorTypes,
},
user.toRow(),
);
const newBackupCodes = this.authService.generateBackupCodes();
const mfaBackupCodes = await this.userAuthRepository.createMfaBackupCodes(userId, newBackupCodes);
await this.dispatchUserUpdate(updatedUser);
if (updatedUser) {
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
}
return mfaBackupCodes;
}
async disableMfaTotp(params: {user: User; code: string; sudoContext: SudoVerificationResult}): Promise<void> {
const {user, code, sudoContext} = params;
if (!user.totpSecret) throw new MfaNotEnabledError();
const identityVerifiedViaSudo = sudoContext.method === 'mfa' || sudoContext.method === 'sudo_token';
const identityVerifiedViaPassword = sudoContext.method === 'password';
const hasMfa = userHasMfa(user);
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
throw new SudoModeRequiredError(hasMfa);
}
if (
!(await this.authService.verifyMfaCode({
userId: user.id,
mfaSecret: user.totpSecret,
code,
allowBackup: true,
}))
) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
const userId = user.id;
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
authenticatorTypes.delete(UserAuthenticatorTypes.TOTP);
const hasSms = authenticatorTypes.has(UserAuthenticatorTypes.SMS);
if (hasSms) {
authenticatorTypes.delete(UserAuthenticatorTypes.SMS);
}
const updatedUser = await this.userAccountRepository.patchUpsert(
userId,
{
totp_secret: null,
authenticator_types: authenticatorTypes,
},
user.toRow(),
);
await this.userAuthRepository.clearMfaBackupCodes(userId);
await this.dispatchUserUpdate(updatedUser);
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
}
async getMfaBackupCodes(params: {
user: User;
regenerate: boolean;
sudoContext: SudoVerificationResult;
}): Promise<Array<MfaBackupCode>> {
const {user, regenerate, sudoContext} = params;
const identityVerifiedViaSudo = sudoContext.method === 'mfa' || sudoContext.method === 'sudo_token';
const identityVerifiedViaPassword = sudoContext.method === 'password';
const hasMfa = userHasMfa(user);
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
throw new SudoModeRequiredError(hasMfa);
}
if (regenerate) {
return this.regenerateMfaBackupCodes(user);
}
return await this.userAuthRepository.listMfaBackupCodes(user.id);
}
async regenerateMfaBackupCodes(user: User): Promise<Array<MfaBackupCode>> {
const userId = user.id;
const newBackupCodes = this.authService.generateBackupCodes();
await this.userAuthRepository.clearMfaBackupCodes(userId);
return await this.userAuthRepository.createMfaBackupCodes(userId, newBackupCodes);
}
async verifyEmail(token: string): Promise<boolean> {
const emailToken = await this.userAuthRepository.getEmailVerificationToken(token);
if (!emailToken) {
return false;
}
const user = await this.userAccountRepository.findUnique(emailToken.userId);
if (!user) {
return false;
}
const updatedUser = await this.userAccountRepository.patchUpsert(
emailToken.userId,
{
email: emailToken.email,
email_verified: true,
},
user.toRow(),
);
await this.userAuthRepository.deleteEmailVerificationToken(token);
await this.dispatchUserUpdate(updatedUser);
return true;
}
async resendVerificationEmail(user: User): Promise<boolean> {
if (user.emailVerified) {
return true;
}
const email = user.email;
if (!email) {
return false;
}
const verificationToken = createEmailVerificationToken(RandomUtils.randomString(64));
await this.userAuthRepository.createEmailVerificationToken({
token_: verificationToken,
user_id: user.id,
email,
});
await this.emailService.sendEmailVerification(email, user.username, verificationToken, user.locale);
return true;
}
async dispatchUserUpdate(user: User): Promise<void> {
await this.gatewayService.dispatchPresence({
userId: user.id,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(user),
});
}
}

View File

@@ -0,0 +1,86 @@
/*
* 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 type {ChannelID, UserID} from '@fluxer/api/src/BrandedTypes';
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {UserService} from '@fluxer/api/src/user/services/UserService';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
interface UserChannelListParams {
userId: UserID;
requestCache: RequestCache;
}
interface UserChannelCreateParams {
userId: UserID;
data: CreatePrivateChannelRequest;
requestCache: RequestCache;
}
interface UserChannelPinParams {
userId: UserID;
channelId: ChannelID;
}
export class UserChannelRequestService {
constructor(
private readonly userService: UserService,
private readonly userCacheService: UserCacheService,
) {}
async listPrivateChannels(params: UserChannelListParams): Promise<Array<ChannelResponse>> {
const channels = await this.userService.getPrivateChannels(params.userId);
return Promise.all(
channels.map((channel) =>
mapChannelToResponse({
channel,
currentUserId: params.userId,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
}),
),
);
}
async createPrivateChannel(params: UserChannelCreateParams): Promise<ChannelResponse> {
const channel = await this.userService.createOrOpenDMChannel({
userId: params.userId,
data: params.data,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
return mapChannelToResponse({
channel,
currentUserId: params.userId,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
}
async pinChannel(params: UserChannelPinParams): Promise<void> {
await this.userService.pinDmChannel({userId: params.userId, channelId: params.channelId});
}
async unpinChannel(params: UserChannelPinParams): Promise<void> {
await this.userService.unpinDmChannel({userId: params.userId, channelId: params.channelId});
}
}

View File

@@ -0,0 +1,547 @@
/*
* 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 type {ChannelID, UserID} from '@fluxer/api/src/BrandedTypes';
import {createChannelID, createMessageID, createUserID} from '@fluxer/api/src/BrandedTypes';
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
import {dispatchMessageCreate} from '@fluxer/api/src/channel/services/group_dm/GroupDmHelpers';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {Channel} from '@fluxer/api/src/models/Channel';
import type {Message} from '@fluxer/api/src/models/Message';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
import type {UserPermissionUtils} from '@fluxer/api/src/utils/UserPermissionUtils';
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {MAX_GROUP_DM_RECIPIENTS, MAX_GROUP_DMS_PER_USER} from '@fluxer/constants/src/LimitConstants';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {CannotSendMessagesToUserError} from '@fluxer/errors/src/domains/channel/CannotSendMessagesToUserError';
import {MaxGroupDmRecipientsError} from '@fluxer/errors/src/domains/channel/MaxGroupDmRecipientsError';
import {MaxGroupDmsError} from '@fluxer/errors/src/domains/channel/MaxGroupDmsError';
import {UnclaimedAccountCannotSendDirectMessagesError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotSendDirectMessagesError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {NotFriendsWithUserError} from '@fluxer/errors/src/domains/user/NotFriendsWithUserError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
export class UserChannelService {
constructor(
private userAccountRepository: IUserAccountRepository,
private userChannelRepository: IUserChannelRepository,
private userRelationshipRepository: IUserRelationshipRepository,
private channelService: ChannelService,
private channelRepository: IChannelRepository,
private gatewayService: IGatewayService,
private mediaService: IMediaService,
private snowflakeService: SnowflakeService,
private userPermissionUtils: UserPermissionUtils,
private readonly limitConfigService: LimitConfigService,
) {}
async getPrivateChannels(userId: UserID): Promise<Array<Channel>> {
return await this.userChannelRepository.listPrivateChannels(userId);
}
async createOrOpenDMChannel({
userId,
data,
userCacheService,
requestCache,
}: {
userId: UserID;
data: CreatePrivateChannelRequest;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
if (data.recipients !== undefined) {
return await this.createGroupDMChannel({
userId,
recipients: data.recipients,
userCacheService,
requestCache,
});
}
if (!data.recipient_id) {
throw InputValidationError.fromCode('recipient_id', ValidationErrorCodes.RECIPIENT_IDS_CANNOT_BE_EMPTY);
}
const recipientId = createUserID(data.recipient_id);
if (userId === recipientId) {
throw InputValidationError.fromCode('recipient_id', ValidationErrorCodes.CANNOT_DM_YOURSELF);
}
const targetUser = await this.userAccountRepository.findUnique(recipientId);
if (!targetUser) throw new UnknownUserError();
const existingChannel = await this.userChannelRepository.findExistingDmState(userId, recipientId);
if (existingChannel) {
return await this.reopenExistingDMChannel({userId, existingChannel, userCacheService, requestCache});
}
await this.validateDmPermission(userId, recipientId, targetUser);
const channel = await this.createNewDMChannel({userId, recipientId, userCacheService, requestCache});
return channel;
}
async pinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
const channel = await this.channelService.getChannel({userId, channelId});
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw InputValidationError.fromCode('channel_id', ValidationErrorCodes.CHANNEL_MUST_BE_DM_OR_GROUP_DM);
}
if (!channel.recipientIds.has(userId)) {
throw new MissingAccessError();
}
const newPinnedDMs = await this.userChannelRepository.addPinnedDm(userId, channelId);
await this.gatewayService.dispatchPresence({
userId: userId,
event: 'USER_PINNED_DMS_UPDATE',
data: newPinnedDMs.map(String),
});
}
async unpinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
const channel = await this.channelService.getChannel({userId, channelId});
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw InputValidationError.fromCode('channel_id', ValidationErrorCodes.CHANNEL_MUST_BE_DM_OR_GROUP_DM);
}
if (!channel.recipientIds.has(userId)) {
throw new MissingAccessError();
}
const newPinnedDMs = await this.userChannelRepository.removePinnedDm(userId, channelId);
await this.gatewayService.dispatchPresence({
userId: userId,
event: 'USER_PINNED_DMS_UPDATE',
data: newPinnedDMs.map(String),
});
}
async preloadDMMessages(params: {
userId: UserID;
channelIds: Array<ChannelID>;
}): Promise<Record<string, Message | null>> {
const {userId, channelIds} = params;
if (channelIds.length > 100) {
throw InputValidationError.fromCode('channels', ValidationErrorCodes.CANNOT_PRELOAD_MORE_THAN_100_CHANNELS);
}
const results: Record<string, Message | null> = {};
const fetchPromises = channelIds.map(async (channelId) => {
try {
const channel = await this.channelService.getChannel({userId, channelId});
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
return;
}
if (!channel.recipientIds.has(userId)) {
return;
}
const messages = await this.channelService.getMessages({
userId,
channelId,
limit: 1,
before: undefined,
after: undefined,
around: undefined,
});
results[channelId.toString()] = messages[0] ?? null;
} catch {
results[channelId.toString()] = null;
}
});
await Promise.all(fetchPromises);
return results;
}
async getExistingDmForUsers(userId: UserID, recipientId: UserID): Promise<Channel | null> {
return await this.userChannelRepository.findExistingDmState(userId, recipientId);
}
async ensureDmOpenForBothUsers({
userId,
recipientId,
userCacheService,
requestCache,
}: {
userId: UserID;
recipientId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
const existingChannel = await this.userChannelRepository.findExistingDmState(userId, recipientId);
if (existingChannel) {
const [isUserOpen, isRecipientOpen] = await Promise.all([
this.userChannelRepository.isDmChannelOpen(userId, existingChannel.id),
this.userChannelRepository.isDmChannelOpen(recipientId, existingChannel.id),
]);
if (!isUserOpen) {
await this.userChannelRepository.openDmForUser(userId, existingChannel.id);
await this.dispatchChannelCreate({userId, channel: existingChannel, userCacheService, requestCache});
}
if (!isRecipientOpen) {
await this.userChannelRepository.openDmForUser(recipientId, existingChannel.id);
await this.dispatchChannelCreate({
userId: recipientId,
channel: existingChannel,
userCacheService,
requestCache,
});
}
return existingChannel;
}
return await this.createNewDmForBothUsers({userId, recipientId, userCacheService, requestCache});
}
async reopenDmForBothUsers({
userId,
recipientId,
existingChannel,
userCacheService,
requestCache,
}: {
userId: UserID;
recipientId: UserID;
existingChannel: Channel;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
await this.reopenExistingDMChannel({userId, existingChannel, userCacheService, requestCache});
await this.reopenExistingDMChannel({
userId: recipientId,
existingChannel,
userCacheService,
requestCache,
});
}
async createNewDmForBothUsers({
userId,
recipientId,
userCacheService,
requestCache,
}: {
userId: UserID;
recipientId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
const newChannel = await this.createNewDMChannel({
userId,
recipientId,
userCacheService,
requestCache,
});
await this.userChannelRepository.openDmForUser(recipientId, newChannel.id);
await this.dispatchChannelCreate({userId: recipientId, channel: newChannel, userCacheService, requestCache});
return newChannel;
}
private async reopenExistingDMChannel({
userId,
existingChannel,
userCacheService,
requestCache,
}: {
userId: UserID;
existingChannel: Channel;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
await this.userChannelRepository.openDmForUser(userId, existingChannel.id);
await this.dispatchChannelCreate({userId, channel: existingChannel, userCacheService, requestCache});
return existingChannel;
}
private async createNewDMChannel({
userId,
recipientId,
userCacheService,
requestCache,
}: {
userId: UserID;
recipientId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
const channelId = createChannelID(await this.snowflakeService.generate());
const newChannel = await this.userChannelRepository.createDmChannelAndState(userId, recipientId, channelId);
await this.userChannelRepository.openDmForUser(userId, channelId);
await this.dispatchChannelCreate({userId, channel: newChannel, userCacheService, requestCache});
return newChannel;
}
private async createGroupDMChannel({
userId,
recipients,
userCacheService,
requestCache,
}: {
userId: UserID;
recipients: Array<bigint>;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
const fallbackRecipientLimit = MAX_GROUP_DM_RECIPIENTS;
const recipientLimit = this.resolveLimitForUser(
await this.userAccountRepository.findUnique(userId),
'max_group_dm_recipients',
fallbackRecipientLimit,
);
if (recipients.length > recipientLimit) {
throw new MaxGroupDmRecipientsError(recipientLimit);
}
const recipientIds = recipients.map(createUserID);
const uniqueRecipientIds = new Set(recipientIds);
if (uniqueRecipientIds.size !== recipientIds.length) {
throw InputValidationError.fromCode('recipients', ValidationErrorCodes.DUPLICATE_RECIPIENTS_NOT_ALLOWED);
}
if (uniqueRecipientIds.has(userId)) {
throw InputValidationError.fromCode('recipients', ValidationErrorCodes.CANNOT_ADD_YOURSELF_TO_GROUP_DM);
}
const usersToCheck = new Set<UserID>([userId, ...recipientIds]);
await this.ensureUsersWithinGroupDmLimit(usersToCheck);
for (const recipientId of recipientIds) {
const targetUser = await this.userAccountRepository.findUnique(recipientId);
if (!targetUser) {
throw new UnknownUserError();
}
const friendship = await this.userRelationshipRepository.getRelationship(
userId,
recipientId,
RelationshipTypes.FRIEND,
);
if (!friendship) {
throw new NotFriendsWithUserError();
}
await this.userPermissionUtils.validateGroupDmAddPermissions({userId, targetId: recipientId});
}
const channelId = createChannelID(await this.snowflakeService.generate());
const allRecipients = new Set([userId, ...recipientIds]);
const channelData = {
channel_id: channelId,
guild_id: null,
type: ChannelTypes.GROUP_DM,
name: null,
topic: null,
icon_hash: null,
url: null,
parent_id: null,
position: 0,
owner_id: userId,
recipient_ids: allRecipients,
nsfw: false,
rate_limit_per_user: 0,
bitrate: null,
user_limit: null,
rtc_region: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
nicks: null,
soft_deleted: false,
indexed_at: null,
version: 1,
};
const newChannel = await this.channelRepository.upsert(channelData);
for (const recipientId of allRecipients) {
await this.userChannelRepository.openDmForUser(recipientId, channelId);
}
const systemMessages: Array<Message> = [];
for (const recipientId of recipientIds) {
const messageId = createMessageID(await this.snowflakeService.generate());
const message = await this.channelRepository.upsertMessage({
channel_id: channelId,
bucket: BucketUtils.makeBucket(messageId),
message_id: messageId,
author_id: userId,
type: MessageTypes.RECIPIENT_ADD,
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([recipientId]),
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,
});
systemMessages.push(message);
}
for (const recipientId of allRecipients) {
await this.dispatchChannelCreate({userId: recipientId, channel: newChannel, userCacheService, requestCache});
}
for (const message of systemMessages) {
await this.dispatchSystemMessage({
channel: newChannel,
message,
userCacheService,
requestCache,
});
}
return newChannel;
}
private async dispatchSystemMessage({
channel,
message,
userCacheService,
requestCache,
}: {
channel: Channel;
message: Message;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
await dispatchMessageCreate({
channel,
message,
requestCache,
userCacheService,
gatewayService: this.gatewayService,
mediaService: this.mediaService,
getReferencedMessage: (channelId, messageId) => this.channelRepository.getMessage(channelId, messageId),
});
}
private async dispatchChannelCreate({
userId,
channel,
userCacheService,
requestCache,
}: {
userId: UserID;
channel: Channel;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
const channelResponse = await mapChannelToResponse({
channel,
currentUserId: userId,
userCacheService,
requestCache,
});
await this.gatewayService.dispatchPresence({
userId,
event: 'CHANNEL_CREATE',
data: channelResponse,
});
}
private async ensureUsersWithinGroupDmLimit(userIds: Iterable<UserID>): Promise<void> {
for (const userId of userIds) {
await this.ensureUserWithinGroupDmLimit(userId);
}
}
private async ensureUserWithinGroupDmLimit(userId: UserID): Promise<void> {
const summaries = await this.userChannelRepository.listPrivateChannelSummaries(userId);
const openGroupDms = summaries.filter((summary) => summary.open && summary.isGroupDm).length;
const user = await this.userAccountRepository.findUnique(userId);
const fallbackLimit = MAX_GROUP_DMS_PER_USER;
const limit = this.resolveLimitForUser(user ?? null, 'max_group_dms_per_user', fallbackLimit);
if (openGroupDms >= limit) {
throw new MaxGroupDmsError(limit);
}
}
private async validateDmPermission(userId: UserID, recipientId: UserID, _recipientUser?: User | null): Promise<void> {
const senderUser = await this.userAccountRepository.findUnique(userId);
if (senderUser?.isUnclaimedAccount()) {
throw new UnclaimedAccountCannotSendDirectMessagesError();
}
const userBlockedRecipient = await this.userRelationshipRepository.getRelationship(
userId,
recipientId,
RelationshipTypes.BLOCKED,
);
if (userBlockedRecipient) {
throw new CannotSendMessagesToUserError();
}
const recipientBlockedUser = await this.userRelationshipRepository.getRelationship(
recipientId,
userId,
RelationshipTypes.BLOCKED,
);
if (recipientBlockedUser) {
throw new CannotSendMessagesToUserError();
}
const friendship = await this.userRelationshipRepository.getRelationship(
userId,
recipientId,
RelationshipTypes.FRIEND,
);
if (friendship) return;
const hasMutualGuilds = await this.userPermissionUtils.checkMutualGuildsAsync({
userId,
targetId: recipientId,
});
if (hasMutualGuilds) return;
throw new CannotSendMessagesToUserError();
}
private resolveLimitForUser(user: User | null, key: LimitKey, fallback: number): number {
const ctx = createLimitMatchContext({user});
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback);
}
}

View File

@@ -0,0 +1,118 @@
/*
* 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 type {UserID} from '@fluxer/api/src/BrandedTypes';
import type {UserContactChangeLogRow} from '@fluxer/api/src/database/types/UserTypes';
import type {User} from '@fluxer/api/src/models/User';
import type {UserContactChangeLogRepository} from '@fluxer/api/src/user/repositories/UserContactChangeLogRepository';
export type ContactChangeReason = 'user_requested' | 'admin_action';
interface RecordDiffParams {
oldUser: User | null;
newUser: User;
reason: ContactChangeReason;
actorUserId: UserID | null;
eventAt?: Date;
}
interface ListLogsParams {
userId: UserID;
limit?: number;
beforeEventId?: string;
}
export class UserContactChangeLogService {
private readonly DEFAULT_LIMIT = 50;
constructor(private readonly repo: UserContactChangeLogRepository) {}
async recordDiff(params: RecordDiffParams): Promise<void> {
const {oldUser, newUser, reason, actorUserId, eventAt} = params;
const tasks: Array<Promise<void>> = [];
const oldEmail = oldUser?.email?.toLowerCase() ?? null;
const newEmail = newUser.email?.toLowerCase() ?? null;
if (oldEmail !== newEmail) {
tasks.push(
this.repo.insertLog({
userId: newUser.id,
field: 'email',
oldValue: oldEmail,
newValue: newEmail,
reason,
actorUserId,
eventAt,
}),
);
}
const oldPhone = oldUser?.phone ?? null;
const newPhone = newUser.phone ?? null;
if (oldPhone !== newPhone) {
tasks.push(
this.repo.insertLog({
userId: newUser.id,
field: 'phone',
oldValue: oldPhone,
newValue: newPhone,
reason,
actorUserId,
eventAt,
}),
);
}
const oldTag = oldUser ? this.buildFluxerTag(oldUser) : null;
const newTag = this.buildFluxerTag(newUser);
if (oldTag !== newTag) {
tasks.push(
this.repo.insertLog({
userId: newUser.id,
field: 'fluxer_tag',
oldValue: oldTag,
newValue: newTag,
reason,
actorUserId,
eventAt,
}),
);
}
if (tasks.length > 0) {
await Promise.all(tasks);
}
}
async listLogs(params: ListLogsParams): Promise<Array<UserContactChangeLogRow>> {
const {userId, beforeEventId} = params;
const limit = params.limit ?? this.DEFAULT_LIMIT;
return this.repo.listLogs({userId, limit, beforeEventId});
}
private buildFluxerTag(user: User | null): string | null {
if (!user) return null;
const discriminator = user.discriminator?.toString() ?? '';
if (!user.username || discriminator === '') {
return null;
}
const paddedDiscriminator = discriminator.padStart(4, '0');
return `${user.username}#${paddedDiscriminator}`;
}
}

View File

@@ -0,0 +1,181 @@
/*
* 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 type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {SavedMessageEntry} from '@fluxer/api/src/user/services/UserContentService';
import type {UserService} from '@fluxer/api/src/user/services/UserService';
import type {MessageListResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {
HarvestCreationResponseSchema,
HarvestDownloadUrlResponse,
HarvestStatusResponseSchema,
} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
import type {
SavedMessageEntryListResponse,
SavedMessageEntryResponse,
} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {z} from 'zod';
type HarvestCreationResponse = z.infer<typeof HarvestCreationResponseSchema>;
type HarvestStatusResponse = z.infer<typeof HarvestStatusResponseSchema>;
type HarvestLatestResponse = HarvestStatusResponse | null;
interface UserMentionsParams {
userId: UserID;
limit: number;
roles: boolean;
everyone: boolean;
guilds: boolean;
before?: MessageID;
requestCache: RequestCache;
}
interface UserMentionDeleteParams {
userId: UserID;
messageId: MessageID;
}
interface SavedMessagesParams {
userId: UserID;
limit: number;
requestCache: RequestCache;
}
interface SaveMessageParams {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
}
interface UnsaveMessageParams {
userId: UserID;
messageId: MessageID;
}
interface HarvestRequestParams {
userId: UserID;
}
interface HarvestStatusParams {
userId: UserID;
harvestId: bigint;
}
interface HarvestDownloadParams {
userId: UserID;
harvestId: bigint;
storageService: IStorageService;
}
export class UserContentRequestService {
constructor(
private readonly userService: UserService,
private readonly userCacheService: UserCacheService,
private readonly mediaService: IMediaService,
) {}
async listMentions(params: UserMentionsParams): Promise<MessageListResponse> {
const messages = await this.userService.getRecentMentions({
userId: params.userId,
limit: params.limit,
everyone: params.everyone,
roles: params.roles,
guilds: params.guilds,
before: params.before,
});
return Promise.all(
messages.map((message) =>
mapMessageToResponse({
message,
currentUserId: params.userId,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
mediaService: this.mediaService,
}),
),
);
}
async deleteMention(params: UserMentionDeleteParams): Promise<void> {
await this.userService.deleteRecentMention({userId: params.userId, messageId: params.messageId});
}
async listSavedMessages(params: SavedMessagesParams): Promise<SavedMessageEntryListResponse> {
const entries = await this.userService.getSavedMessages({userId: params.userId, limit: params.limit});
return Promise.all(entries.map((entry) => this.mapSavedMessageEntry(params.userId, entry, params.requestCache)));
}
async saveMessage(params: SaveMessageParams): Promise<void> {
await this.userService.saveMessage({
userId: params.userId,
channelId: params.channelId,
messageId: params.messageId,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
}
async unsaveMessage(params: UnsaveMessageParams): Promise<void> {
await this.userService.unsaveMessage({userId: params.userId, messageId: params.messageId});
}
async requestHarvest(params: HarvestRequestParams): Promise<HarvestCreationResponse> {
return this.userService.requestDataHarvest(params.userId);
}
async getLatestHarvest(params: HarvestRequestParams): Promise<HarvestLatestResponse> {
return this.userService.getLatestHarvest(params.userId);
}
async getHarvestStatus(params: HarvestStatusParams): Promise<HarvestStatusResponse> {
return this.userService.getHarvestStatus(params.userId, params.harvestId);
}
async getHarvestDownloadUrl(params: HarvestDownloadParams): Promise<HarvestDownloadUrlResponse> {
return this.userService.getHarvestDownloadUrl(params.userId, params.harvestId, params.storageService);
}
private async mapSavedMessageEntry(
userId: UserID,
entry: SavedMessageEntry,
requestCache: RequestCache,
): Promise<SavedMessageEntryResponse> {
return {
id: entry.messageId.toString(),
channel_id: entry.channelId.toString(),
message_id: entry.messageId.toString(),
status: entry.status,
message: entry.message
? await mapMessageToResponse({
message: entry.message,
currentUserId: userId,
userCacheService: this.userCacheService,
requestCache,
mediaService: this.mediaService,
})
: null,
};
}
}

View File

@@ -0,0 +1,482 @@
/*
* 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 crypto from 'node:crypto';
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
import type {PushSubscriptionRow} from '@fluxer/api/src/database/types/UserTypes';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import type {KVBulkMessageDeletionQueueService} from '@fluxer/api/src/infrastructure/KVBulkMessageDeletionQueueService';
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 type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {Message} from '@fluxer/api/src/models/Message';
import type {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserContentRepository} from '@fluxer/api/src/user/repositories/IUserContentRepository';
import {BaseUserUpdatePropagator} from '@fluxer/api/src/user/services/BaseUserUpdatePropagator';
import {UserHarvest, type UserHarvestResponse} from '@fluxer/api/src/user/UserHarvestModel';
import {UserHarvestRepository} from '@fluxer/api/src/user/UserHarvestRepository';
import {MAX_BOOKMARKS_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {MaxBookmarksError} from '@fluxer/errors/src/domains/core/MaxBookmarksError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {HarvestExpiredError} from '@fluxer/errors/src/domains/moderation/HarvestExpiredError';
import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestFailedError';
import {HarvestNotReadyError} from '@fluxer/errors/src/domains/moderation/HarvestNotReadyError';
import {HarvestOnCooldownError} from '@fluxer/errors/src/domains/moderation/HarvestOnCooldownError';
import {UnknownHarvestError} from '@fluxer/errors/src/domains/moderation/UnknownHarvestError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {SavedMessageStatus} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
import {ms} from 'itty-time';
export interface SavedMessageEntry {
channelId: ChannelID;
messageId: MessageID;
status: SavedMessageStatus;
message: Message | null;
}
export class UserContentService {
private readonly updatePropagator: BaseUserUpdatePropagator;
constructor(
private userAccountRepository: IUserAccountRepository,
private userContentRepository: IUserContentRepository,
userCacheService: UserCacheService,
private channelService: ChannelService,
private channelRepository: IChannelRepository,
private gatewayService: IGatewayService,
private mediaService: IMediaService,
private workerService: IWorkerService,
private snowflakeService: SnowflakeService,
private bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService,
private limitConfigService: LimitConfigService,
) {
this.updatePropagator = new BaseUserUpdatePropagator({
userCacheService,
gatewayService: this.gatewayService,
});
}
async getRecentMentions(params: {
userId: UserID;
limit: number;
everyone: boolean;
roles: boolean;
guilds: boolean;
before?: MessageID;
}): Promise<Array<Message>> {
const {userId, limit, everyone, roles, guilds, before} = params;
const mentions = await this.userContentRepository.listRecentMentions(
userId,
everyone,
roles,
guilds,
limit,
before,
);
const messagePromises = mentions.map(async (mention) => {
try {
return await this.channelService.getMessage({
userId,
channelId: mention.channelId,
messageId: mention.messageId,
});
} catch (error) {
if (error instanceof UnknownMessageError) {
return null;
}
throw error;
}
});
const messageResults = await Promise.all(messagePromises);
const messages = messageResults.filter((message): message is Message => message != null);
return messages.sort((a, b) => (b.id > a.id ? 1 : -1));
}
async deleteRecentMention({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
const recentMention = await this.userContentRepository.getRecentMention(userId, messageId);
if (!recentMention) return;
await this.userContentRepository.deleteRecentMention(recentMention);
await this.dispatchRecentMentionDelete({userId, messageId});
}
async getSavedMessages({userId, limit}: {userId: UserID; limit: number}): Promise<Array<SavedMessageEntry>> {
const savedMessages = await this.userContentRepository.listSavedMessages(userId, limit);
const messagePromises = savedMessages.map(async (savedMessage) => {
let message: Message | null = null;
let status: SavedMessageStatus = 'available';
try {
message = await this.channelService.getMessage({
userId,
channelId: savedMessage.channelId,
messageId: savedMessage.messageId,
});
} catch (error) {
if (error instanceof UnknownMessageError) {
await this.userContentRepository.deleteSavedMessage(userId, savedMessage.messageId);
return null;
}
if (error instanceof MissingPermissionsError || error instanceof UnknownChannelError) {
status = 'missing_permissions';
} else {
throw error;
}
}
return {
channelId: savedMessage.channelId,
messageId: savedMessage.messageId,
status,
message,
};
});
const messageResults = await Promise.all(messagePromises);
const results = messageResults.filter((result): result is NonNullable<typeof result> => result != null);
return results.sort((a, b) => (b.messageId > a.messageId ? 1 : a.messageId > b.messageId ? -1 : 0));
}
async saveMessage({
userId,
channelId,
messageId,
userCacheService,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
const user = await this.userAccountRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const savedMessages = await this.userContentRepository.listSavedMessages(userId, 1000);
const ctx = createLimitMatchContext({user});
const maxBookmarks = resolveLimitSafe(
this.limitConfigService.getConfigSnapshot(),
ctx,
'max_bookmarks',
MAX_BOOKMARKS_NON_PREMIUM,
);
if (savedMessages.length >= maxBookmarks) {
throw new MaxBookmarksError({maxBookmarks});
}
await this.channelService.getChannelAuthenticated({userId, channelId});
const message = await this.channelService.getMessage({userId, channelId, messageId});
if (!message) {
throw new UnknownMessageError();
}
await this.userContentRepository.createSavedMessage(userId, channelId, messageId);
await this.dispatchSavedMessageCreate({userId, message, userCacheService, requestCache});
}
async unsaveMessage({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
await this.userContentRepository.deleteSavedMessage(userId, messageId);
await this.dispatchSavedMessageDelete({userId, messageId});
}
async registerPushSubscription(params: {
userId: UserID;
endpoint: string;
keys: {p256dh: string; auth: string};
userAgent?: string;
}): Promise<PushSubscription> {
const {userId, endpoint, keys, userAgent} = params;
const subscriptionId = crypto.createHash('sha256').update(endpoint).digest('hex').substring(0, 32);
const data: PushSubscriptionRow = {
user_id: userId,
subscription_id: subscriptionId,
endpoint,
p256dh_key: keys.p256dh,
auth_key: keys.auth,
user_agent: userAgent ?? null,
};
return await this.userContentRepository.createPushSubscription(data);
}
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
return await this.userContentRepository.listPushSubscriptions(userId);
}
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
await this.userContentRepository.deletePushSubscription(userId, subscriptionId);
}
async requestDataHarvest(userId: UserID): Promise<{
harvest_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
created_at: string;
}> {
const user = await this.userAccountRepository.findUnique(userId);
if (!user) throw new UnknownUserError();
if (!Config.dev.testModeEnabled) {
const harvestRepository = new UserHarvestRepository();
const latestHarvest = await harvestRepository.findLatestByUserId(userId);
if (latestHarvest?.requestedAt) {
const sevenDaysAgo = new Date(Date.now() - ms('7 days'));
if (latestHarvest.requestedAt > sevenDaysAgo) {
const retryAfter = new Date(latestHarvest.requestedAt.getTime() + ms('7 days'));
throw new HarvestOnCooldownError({retryAfter});
}
}
}
const harvestId = await this.snowflakeService.generate();
const harvest = new UserHarvest({
user_id: userId,
harvest_id: harvestId,
requested_at: new Date(),
started_at: null,
completed_at: null,
failed_at: null,
storage_key: null,
file_size: null,
progress_percent: 0,
progress_step: 'Queued',
error_message: null,
download_url_expires_at: null,
});
const harvestRepository = new UserHarvestRepository();
await harvestRepository.create(harvest);
await this.workerService.addJob('harvestUserData', {
userId: userId.toString(),
harvestId: harvestId.toString(),
});
return {
harvest_id: harvest.harvestId.toString(),
status: harvest.getStatus(),
created_at: harvest.requestedAt.toISOString(),
};
}
async getHarvestStatus(userId: UserID, harvestId: bigint): Promise<UserHarvestResponse> {
const harvestRepository = new UserHarvestRepository();
const harvest = await harvestRepository.findByUserAndHarvestId(userId, harvestId);
if (!harvest) {
throw new UnknownHarvestError();
}
return harvest.toResponse();
}
async getLatestHarvest(userId: UserID): Promise<UserHarvestResponse | null> {
const harvestRepository = new UserHarvestRepository();
const harvest = await harvestRepository.findLatestByUserId(userId);
return harvest ? harvest.toResponse() : null;
}
async getHarvestDownloadUrl(
userId: UserID,
harvestId: bigint,
storageService: IStorageService,
): Promise<{download_url: string; expires_at: string}> {
const harvestRepository = new UserHarvestRepository();
const harvest = await harvestRepository.findByUserAndHarvestId(userId, harvestId);
if (!harvest) {
throw new UnknownHarvestError();
}
if (!harvest.completedAt || !harvest.storageKey) {
throw new HarvestNotReadyError();
}
if (harvest.failedAt) {
throw new HarvestFailedError();
}
if (harvest.downloadUrlExpiresAt && harvest.downloadUrlExpiresAt < new Date()) {
throw new HarvestExpiredError();
}
const ZIP_EXPIRY_MS = ms('7 days');
const downloadUrl = await storageService.getPresignedDownloadURL({
bucket: Config.s3.buckets.harvests,
key: harvest.storageKey,
expiresIn: ZIP_EXPIRY_MS / 1000,
});
const expiresAt = new Date(Date.now() + ZIP_EXPIRY_MS);
return {
download_url: downloadUrl,
expires_at: expiresAt.toISOString(),
};
}
async requestBulkMessageDeletion(params: {userId: UserID; delayMs?: number}): Promise<void> {
const {userId, delayMs = ms('1 day')} = params;
const scheduledAt = new Date(Date.now() + delayMs);
const user = await this.userAccountRepository.findUniqueAssert(userId);
await this.bulkMessageDeletionQueue.removeFromQueue(userId);
const counts = await this.countBulkDeletionTargets(userId, scheduledAt.getTime());
Logger.debug(
{
userId: userId.toString(),
channelCount: counts.channelCount,
messageCount: counts.messageCount,
scheduledAt: scheduledAt.toISOString(),
},
'Scheduling bulk message deletion',
);
const updatedUser = await this.userAccountRepository.patchUpsert(
userId,
{
pending_bulk_message_deletion_at: scheduledAt,
pending_bulk_message_deletion_channel_count: counts.channelCount,
pending_bulk_message_deletion_message_count: counts.messageCount,
},
user.toRow(),
);
await this.bulkMessageDeletionQueue.scheduleDeletion(userId, scheduledAt);
await this.updatePropagator.dispatchUserUpdate(updatedUser);
}
async cancelBulkMessageDeletion(userId: UserID): Promise<void> {
Logger.debug({userId: userId.toString()}, 'Canceling pending bulk message deletion');
const user = await this.userAccountRepository.findUniqueAssert(userId);
const updatedUser = await this.userAccountRepository.patchUpsert(
userId,
{
pending_bulk_message_deletion_at: null,
pending_bulk_message_deletion_channel_count: null,
pending_bulk_message_deletion_message_count: null,
},
user.toRow(),
);
await this.bulkMessageDeletionQueue.removeFromQueue(userId);
await this.updatePropagator.dispatchUserUpdate(updatedUser);
}
private async countBulkDeletionTargets(
userId: UserID,
cutoffMs: number,
): Promise<{
channelCount: number;
messageCount: number;
}> {
const CHUNK_SIZE = 200;
let lastMessageId: MessageID | undefined;
const channels = new Set<string>();
let messageCount = 0;
while (true) {
const messageRefs = await this.channelRepository.listMessagesByAuthor(userId, CHUNK_SIZE, lastMessageId);
if (messageRefs.length === 0) {
break;
}
for (const {channelId, messageId} of messageRefs) {
if (snowflakeToDate(messageId).getTime() > cutoffMs) {
continue;
}
channels.add(channelId.toString());
messageCount++;
}
lastMessageId = messageRefs[messageRefs.length - 1].messageId;
if (messageRefs.length < CHUNK_SIZE) {
break;
}
}
return {
channelCount: channels.size,
messageCount,
};
}
async dispatchRecentMentionDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
await this.gatewayService.dispatchPresence({
userId,
event: 'RECENT_MENTION_DELETE',
data: {message_id: messageId.toString()},
});
}
async dispatchSavedMessageCreate({
userId,
message,
userCacheService,
requestCache,
}: {
userId: UserID;
message: Message;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
await this.gatewayService.dispatchPresence({
userId,
event: 'SAVED_MESSAGE_CREATE',
data: await mapMessageToResponse({
message,
currentUserId: userId,
userCacheService,
requestCache,
mediaService: this.mediaService,
}),
});
}
async dispatchSavedMessageDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
await this.gatewayService.dispatchPresence({
userId,
event: 'SAVED_MESSAGE_DELETE',
data: {message_id: messageId.toString()},
});
}
}

View File

@@ -0,0 +1,126 @@
/*
* 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 type {UserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {User} from '@fluxer/api/src/models/User';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
import {ms, seconds} from 'itty-time';
export class UserDeletionEligibilityService {
private readonly INACTIVITY_WARNING_TTL_DAYS = 30;
private readonly INACTIVITY_WARNING_PREFIX = 'inactivity_warning_sent';
constructor(private kvClient: IKVProvider) {}
async isEligibleForInactivityDeletion(user: User): Promise<boolean> {
if (user.isBot) {
return false;
}
if (user.isSystem) {
return false;
}
if (this.isAppStoreReviewer(user)) {
return false;
}
if (user.pendingDeletionAt !== null) {
return false;
}
if (user.lastActiveAt === null) {
return false;
}
const inactivityThresholdMs = this.getInactivityThresholdMs();
const timeSinceLastActiveMs = Date.now() - user.lastActiveAt.getTime();
if (timeSinceLastActiveMs < inactivityThresholdMs) {
return false;
}
return true;
}
async isEligibleForWarningEmail(user: User): Promise<boolean> {
const isEligibleForDeletion = await this.isEligibleForInactivityDeletion(user);
if (!isEligibleForDeletion) {
return false;
}
const alreadySentWarning = await this.hasWarningSent(user.id);
if (alreadySentWarning) {
return false;
}
return true;
}
async markWarningSent(userId: UserID): Promise<void> {
const key = this.getWarningKey(userId);
const ttlSeconds = seconds(`${this.INACTIVITY_WARNING_TTL_DAYS + 5} days`);
const timestamp = Date.now().toString();
await this.kvClient.setex(key, ttlSeconds, timestamp);
}
async hasWarningSent(userId: UserID): Promise<boolean> {
const key = this.getWarningKey(userId);
const exists = await this.kvClient.exists(key);
return exists === 1;
}
async getWarningSentTimestamp(userId: UserID): Promise<number | null> {
const key = this.getWarningKey(userId);
const value = await this.kvClient.get(key);
if (!value) {
return null;
}
const timestamp = parseInt(value, 10);
return Number.isNaN(timestamp) ? null : timestamp;
}
async hasWarningGracePeriodExpired(userId: UserID): Promise<boolean> {
const timestamp = await this.getWarningSentTimestamp(userId);
if (timestamp === null) {
return false;
}
const timeSinceWarningMs = Date.now() - timestamp;
const gracePeriodMs = this.INACTIVITY_WARNING_TTL_DAYS * ms('1 day');
return timeSinceWarningMs >= gracePeriodMs;
}
private getInactivityThresholdMs(): number {
const thresholdDays = Config.inactivityDeletionThresholdDays ?? 365 * 2;
return thresholdDays * ms('1 day');
}
private getWarningKey(userId: UserID): string {
return `${this.INACTIVITY_WARNING_PREFIX}:${userId}`;
}
private isAppStoreReviewer(user: User): boolean {
return (user.flags & UserFlags.APP_STORE_REVIEWER) !== 0n;
}
}

View File

@@ -0,0 +1,519 @@
/*
* 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',
},
});
}

View File

@@ -0,0 +1,146 @@
/*
* 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 type {UserID} from '@fluxer/api/src/BrandedTypes';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {UserService} from '@fluxer/api/src/user/services/UserService';
import {getCachedUserPartialResponse} from '@fluxer/api/src/user/UserCacheHelpers';
import {mapRelationshipToResponse} from '@fluxer/api/src/user/UserMappers';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import type {
FriendRequestByTagRequest,
RelationshipNicknameUpdateRequest,
RelationshipTypePutRequest,
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {RelationshipResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
interface RelationshipListParams {
userId: UserID;
requestCache: RequestCache;
}
interface RelationshipSendByTagParams {
userId: UserID;
data: FriendRequestByTagRequest;
requestCache: RequestCache;
}
interface RelationshipSendParams {
userId: UserID;
targetId: UserID;
requestCache: RequestCache;
}
interface RelationshipUpdateTypeParams {
userId: UserID;
targetId: UserID;
data: RelationshipTypePutRequest;
requestCache: RequestCache;
}
interface RelationshipDeleteParams {
userId: UserID;
targetId: UserID;
}
interface RelationshipNicknameParams {
userId: UserID;
targetId: UserID;
data: RelationshipNicknameUpdateRequest;
requestCache: RequestCache;
}
export class UserRelationshipRequestService {
constructor(
private readonly userService: UserService,
private readonly userCacheService: UserCacheService,
) {}
async listRelationships(params: RelationshipListParams): Promise<Array<RelationshipResponse>> {
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
const relationships = await this.userService.getRelationships(params.userId);
return Promise.all(
relationships.map((relationship) => mapRelationshipToResponse({relationship, userPartialResolver})),
);
}
async sendFriendRequestByTag(params: RelationshipSendByTagParams): Promise<RelationshipResponse> {
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
const relationship = await this.userService.sendFriendRequestByTag({
userId: params.userId,
data: params.data,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
return mapRelationshipToResponse({relationship, userPartialResolver});
}
async sendFriendRequest(params: RelationshipSendParams): Promise<RelationshipResponse> {
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
const relationship = await this.userService.sendFriendRequest({
userId: params.userId,
targetId: params.targetId,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
return mapRelationshipToResponse({relationship, userPartialResolver});
}
async updateRelationshipType(params: RelationshipUpdateTypeParams): Promise<RelationshipResponse> {
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
if (params.data?.type === RelationshipTypes.BLOCKED) {
const relationship = await this.userService.blockUser({
userId: params.userId,
targetId: params.targetId,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
return mapRelationshipToResponse({relationship, userPartialResolver});
}
const relationship = await this.userService.acceptFriendRequest({
userId: params.userId,
targetId: params.targetId,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
return mapRelationshipToResponse({relationship, userPartialResolver});
}
async removeRelationship(params: RelationshipDeleteParams): Promise<void> {
await this.userService.removeRelationship({userId: params.userId, targetId: params.targetId});
}
async updateNickname(params: RelationshipNicknameParams): Promise<RelationshipResponse> {
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
const relationship = await this.userService.updateFriendNickname({
userId: params.userId,
targetId: params.targetId,
nickname: params.data.nickname ?? null,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
return mapRelationshipToResponse({relationship, userPartialResolver});
}
private createUserPartialResolver(requestCache: RequestCache) {
return (userId: UserID) =>
getCachedUserPartialResponse({userId, userCacheService: this.userCacheService, requestCache});
}
}

View File

@@ -0,0 +1,554 @@
/*
* 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 type {UserID} from '@fluxer/api/src/BrandedTypes';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {Relationship} from '@fluxer/api/src/models/Relationship';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
import {getCachedUserPartialResponse} from '@fluxer/api/src/user/UserCacheHelpers';
import {mapRelationshipToResponse} from '@fluxer/api/src/user/UserMappers';
import type {UserPermissionUtils} from '@fluxer/api/src/utils/UserPermissionUtils';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {MAX_RELATIONSHIPS} from '@fluxer/constants/src/LimitConstants';
import {RelationshipTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
import {BotsCannotSendFriendRequestsError} from '@fluxer/errors/src/domains/oauth/BotsCannotSendFriendRequestsError';
import {AlreadyFriendsError} from '@fluxer/errors/src/domains/user/AlreadyFriendsError';
import {CannotSendFriendRequestToBlockedUserError} from '@fluxer/errors/src/domains/user/CannotSendFriendRequestToBlockedUserError';
import {CannotSendFriendRequestToSelfError} from '@fluxer/errors/src/domains/user/CannotSendFriendRequestToSelfError';
import {FriendRequestBlockedError} from '@fluxer/errors/src/domains/user/FriendRequestBlockedError';
import {InvalidDiscriminatorError} from '@fluxer/errors/src/domains/user/InvalidDiscriminatorError';
import {MaxRelationshipsError} from '@fluxer/errors/src/domains/user/MaxRelationshipsError';
import {NoUsersWithFluxertagError} from '@fluxer/errors/src/domains/user/NoUsersWithFluxertagError';
import {UnclaimedAccountCannotAcceptFriendRequestsError} from '@fluxer/errors/src/domains/user/UnclaimedAccountCannotAcceptFriendRequestsError';
import {UnclaimedAccountCannotSendFriendRequestsError} from '@fluxer/errors/src/domains/user/UnclaimedAccountCannotSendFriendRequestsError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {FriendRequestByTagRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
export class UserRelationshipService {
constructor(
private userAccountRepository: IUserAccountRepository,
private userRelationshipRepository: IUserRelationshipRepository,
private gatewayService: IGatewayService,
private userPermissionUtils: UserPermissionUtils,
private readonly limitConfigService: LimitConfigService,
) {}
async getRelationships(userId: UserID): Promise<Array<Relationship>> {
return await this.userRelationshipRepository.listRelationships(userId);
}
async sendFriendRequestByTag({
userId,
data,
userCacheService,
requestCache,
}: {
userId: UserID;
data: FriendRequestByTagRequest;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const {username, discriminator} = data;
const discrimValue = discriminator;
if (!Number.isInteger(discrimValue) || discrimValue < 0 || discrimValue > 9999) {
throw new InvalidDiscriminatorError();
}
const targetUser = await this.userAccountRepository.findByUsernameDiscriminator(username, discrimValue);
if (!targetUser) {
throw new NoUsersWithFluxertagError();
}
if (this.isDeletedUser(targetUser)) {
throw new FriendRequestBlockedError();
}
const existingRelationship = await this.userRelationshipRepository.getRelationship(
userId,
targetUser.id,
RelationshipTypes.FRIEND,
);
if (existingRelationship) {
throw new AlreadyFriendsError();
}
return this.sendFriendRequest({userId, targetId: targetUser.id, userCacheService, requestCache});
}
async sendFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const targetUser = await this.validateFriendRequest({userId, targetId});
const pendingIncoming = await this.userRelationshipRepository.getRelationship(
targetId,
userId,
RelationshipTypes.OUTGOING_REQUEST,
);
if (pendingIncoming) {
return this.acceptFriendRequest({userId, targetId, userCacheService, requestCache});
}
const existingFriendship = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.FRIEND,
);
const existingOutgoingRequest = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.OUTGOING_REQUEST,
);
if (existingFriendship || existingOutgoingRequest) {
const relationships = await this.userRelationshipRepository.listRelationships(userId);
const relationship = relationships.find((r) => r.targetUserId === targetId);
if (relationship) {
return relationship;
}
}
await this.validateRelationshipCounts({userId, targetId});
const requestRelationship = await this.createFriendRequest({userId, targetId, userCacheService, requestCache});
const targetIsFriendlyBot =
targetUser.isBot && (targetUser.flags & UserFlags.FRIENDLY_BOT) === UserFlags.FRIENDLY_BOT;
const manualApprovalFlag = UserFlags.FRIENDLY_BOT_MANUAL_APPROVAL;
const manualApprovalRequired = targetUser.isBot && (targetUser.flags & manualApprovalFlag) === manualApprovalFlag;
if (targetIsFriendlyBot && !manualApprovalRequired) {
const finalFriendship = await this.acceptFriendRequest({
userId: targetId,
targetId: userId,
userCacheService,
requestCache,
});
return finalFriendship;
}
return requestRelationship;
}
async acceptFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const user = await this.userAccountRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
if (this.isDeletedUser(user)) {
throw new FriendRequestBlockedError();
}
if (user?.isUnclaimedAccount()) {
throw new UnclaimedAccountCannotAcceptFriendRequestsError();
}
const requesterUser = await this.userAccountRepository.findUnique(targetId);
if (!requesterUser) {
throw new UnknownUserError();
}
if (this.isDeletedUser(requesterUser)) {
throw new FriendRequestBlockedError();
}
const incomingRequest = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.INCOMING_REQUEST,
);
if (!incomingRequest) {
throw new UnknownUserError();
}
await this.validateRelationshipCounts({userId, targetId});
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.OUTGOING_REQUEST);
const now = new Date();
const userRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: userId,
target_user_id: targetId,
type: RelationshipTypes.FRIEND,
nickname: null,
since: now,
version: 1,
});
const targetRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: targetId,
target_user_id: userId,
type: RelationshipTypes.FRIEND,
nickname: null,
since: now,
version: 1,
});
await this.dispatchRelationshipUpdate({
userId,
relationship: userRelationship,
userCacheService,
requestCache,
});
await this.dispatchRelationshipUpdate({
userId: targetId,
relationship: targetRelationship,
userCacheService,
requestCache,
});
return userRelationship;
}
async blockUser({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const targetUser = await this.userAccountRepository.findUnique(targetId);
if (!targetUser) {
throw new UnknownUserError();
}
const existingBlocked = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.BLOCKED,
);
if (existingBlocked) {
return existingBlocked;
}
const existingFriend = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.FRIEND,
);
const existingIncomingRequest = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.INCOMING_REQUEST,
);
const existingOutgoingRequest = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.OUTGOING_REQUEST,
);
if (existingFriend) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.FRIEND);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.FRIEND);
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
} else if (existingOutgoingRequest) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.INCOMING_REQUEST);
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
} else if (existingIncomingRequest) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST);
}
const now = new Date();
const blockRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: userId,
target_user_id: targetId,
type: RelationshipTypes.BLOCKED,
nickname: null,
since: now,
version: 1,
});
await this.dispatchRelationshipCreate({
userId,
relationship: blockRelationship,
userCacheService,
requestCache,
});
return blockRelationship;
}
async removeRelationship({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
const [friend, incoming, outgoing, blocked] = await Promise.all([
this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.FRIEND),
this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST),
this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST),
this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.BLOCKED),
]);
const existingRelationship = friend || incoming || outgoing || blocked;
if (!existingRelationship) throw new UnknownUserError();
const relationshipType = existingRelationship.type;
if (relationshipType === RelationshipTypes.INCOMING_REQUEST || relationshipType === RelationshipTypes.BLOCKED) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, relationshipType);
await this.dispatchRelationshipRemove({
userId,
targetId: targetId.toString(),
});
return;
}
if (relationshipType === RelationshipTypes.OUTGOING_REQUEST) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.INCOMING_REQUEST);
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
return;
}
if (relationshipType === RelationshipTypes.FRIEND) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.FRIEND);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.FRIEND);
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
return;
}
await this.userRelationshipRepository.deleteRelationship(userId, targetId, relationshipType);
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
}
async updateFriendNickname({
userId,
targetId,
nickname,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
nickname: string | null;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const relationship = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.FRIEND,
);
if (!relationship) {
throw new UnknownUserError();
}
const updatedRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: userId,
target_user_id: targetId,
type: RelationshipTypes.FRIEND,
nickname,
since: relationship.since ?? new Date(),
version: 1,
});
await this.dispatchRelationshipUpdate({
userId,
relationship: updatedRelationship,
userCacheService,
requestCache,
});
return updatedRelationship;
}
private async validateFriendRequest({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<User> {
if (userId === targetId) {
throw new CannotSendFriendRequestToSelfError();
}
const requesterUser = await this.userAccountRepository.findUnique(userId);
if (!requesterUser) {
throw new UnknownUserError();
}
if (this.isDeletedUser(requesterUser)) {
throw new FriendRequestBlockedError();
}
if (requesterUser?.isUnclaimedAccount()) {
throw new UnclaimedAccountCannotSendFriendRequestsError();
}
if (requesterUser?.isBot) {
throw new BotsCannotSendFriendRequestsError();
}
const targetUser = await this.userAccountRepository.findUnique(targetId);
if (!targetUser) throw new UnknownUserError();
if (this.isDeletedUser(targetUser)) {
throw new FriendRequestBlockedError();
}
const targetIsFriendlyBot =
targetUser.isBot && (targetUser.flags & UserFlags.FRIENDLY_BOT) === UserFlags.FRIENDLY_BOT;
if (targetUser.isBot && !targetIsFriendlyBot) {
throw new FriendRequestBlockedError();
}
if (targetUser.flags & UserFlags.APP_STORE_REVIEWER) {
throw new FriendRequestBlockedError();
}
const requesterBlockedTarget = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.BLOCKED,
);
if (requesterBlockedTarget) {
throw new CannotSendFriendRequestToBlockedUserError();
}
const targetBlockedRequester = await this.userRelationshipRepository.getRelationship(
targetId,
userId,
RelationshipTypes.BLOCKED,
);
if (targetBlockedRequester) {
throw new FriendRequestBlockedError();
}
await this.userPermissionUtils.validateFriendSourcePermissions({userId, targetId});
return targetUser;
}
private async validateRelationshipCounts({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
const user = await this.userAccountRepository.findUnique(userId);
const targetUser = await this.userAccountRepository.findUnique(targetId);
if (!user?.isBot) {
const userLimit = this.resolveLimitForUser(user ?? null, 'max_relationships', MAX_RELATIONSHIPS);
const hasReachedLimit = await this.userRelationshipRepository.hasReachedRelationshipLimit(userId, userLimit);
if (hasReachedLimit) {
throw new MaxRelationshipsError(userLimit);
}
}
if (!targetUser?.isBot) {
const targetLimit = this.resolveLimitForUser(targetUser ?? null, 'max_relationships', MAX_RELATIONSHIPS);
const hasReachedLimit = await this.userRelationshipRepository.hasReachedRelationshipLimit(targetId, targetLimit);
if (hasReachedLimit) {
throw new MaxRelationshipsError(targetLimit);
}
}
}
private async createFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const now = new Date();
const userRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: userId,
target_user_id: targetId,
type: RelationshipTypes.OUTGOING_REQUEST,
nickname: null,
since: now,
version: 1,
});
const targetRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: targetId,
target_user_id: userId,
type: RelationshipTypes.INCOMING_REQUEST,
nickname: null,
since: now,
version: 1,
});
await this.dispatchRelationshipCreate({userId, relationship: userRelationship, userCacheService, requestCache});
await this.dispatchRelationshipCreate({
userId: targetId,
relationship: targetRelationship,
userCacheService,
requestCache,
});
return userRelationship;
}
async dispatchRelationshipCreate({
userId,
relationship,
userCacheService,
requestCache,
}: {
userId: UserID;
relationship: Relationship;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
const userPartialResolver = (userId: UserID) =>
getCachedUserPartialResponse({userId, userCacheService, requestCache});
await this.gatewayService.dispatchPresence({
userId,
event: 'RELATIONSHIP_ADD',
data: await mapRelationshipToResponse({relationship, userPartialResolver}),
});
}
async dispatchRelationshipUpdate({
userId,
relationship,
userCacheService,
requestCache,
}: {
userId: UserID;
relationship: Relationship;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
const userPartialResolver = (userId: UserID) =>
getCachedUserPartialResponse({userId, userCacheService, requestCache});
await this.gatewayService.dispatchPresence({
userId,
event: 'RELATIONSHIP_UPDATE',
data: await mapRelationshipToResponse({relationship, userPartialResolver}),
});
}
async dispatchRelationshipRemove({userId, targetId}: {userId: UserID; targetId: string}): Promise<void> {
await this.gatewayService.dispatchPresence({
userId,
event: 'RELATIONSHIP_REMOVE',
data: {id: targetId},
});
}
private resolveLimitForUser(user: User | null, key: LimitKey, fallback: number): number {
const ctx = createLimitMatchContext({user});
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback);
}
private isDeletedUser(user: User | null | undefined): boolean {
if (!user) {
return false;
}
return (user.flags & UserFlags.DELETED) === UserFlags.DELETED;
}
}

View File

@@ -0,0 +1,622 @@
/*
* 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 type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
import type {ChannelID, GuildID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
import type {IConnectionRepository} from '@fluxer/api/src/connection/IConnectionRepository';
import type {UserConnectionRow} from '@fluxer/api/src/database/types/ConnectionTypes';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
import type {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import type {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAccountDeletionQueueService';
import type {KVBulkMessageDeletionQueueService} from '@fluxer/api/src/infrastructure/KVBulkMessageDeletionQueueService';
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {Channel} from '@fluxer/api/src/models/Channel';
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
import type {Message} from '@fluxer/api/src/models/Message';
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
import type {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
import type {Relationship} from '@fluxer/api/src/models/Relationship';
import type {User} from '@fluxer/api/src/models/User';
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
import type {BotMfaMirrorService} from '@fluxer/api/src/oauth/BotMfaMirrorService';
import type {PackService} from '@fluxer/api/src/pack/PackService';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserAuthRepository} from '@fluxer/api/src/user/repositories/IUserAuthRepository';
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
import type {IUserContentRepository} from '@fluxer/api/src/user/repositories/IUserContentRepository';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
import type {IUserSettingsRepository} from '@fluxer/api/src/user/repositories/IUserSettingsRepository';
import {UserAccountService} from '@fluxer/api/src/user/services/UserAccountService';
import {UserAuthService} from '@fluxer/api/src/user/services/UserAuthService';
import {UserChannelService} from '@fluxer/api/src/user/services/UserChannelService';
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
import type {SavedMessageEntry} from '@fluxer/api/src/user/services/UserContentService';
import {UserContentService} from '@fluxer/api/src/user/services/UserContentService';
import {UserRelationshipService} from '@fluxer/api/src/user/services/UserRelationshipService';
import type {UserHarvestResponse} from '@fluxer/api/src/user/UserHarvestModel';
import type {UserPermissionUtils} from '@fluxer/api/src/utils/UserPermissionUtils';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {
CreatePrivateChannelRequest,
FriendRequestByTagRequest,
UserGuildSettingsUpdateRequest,
UserSettingsUpdateRequest,
UserUpdateRequest,
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
interface UpdateUserParams {
user: User;
oldAuthSession: AuthSession;
data: UserUpdateRequest;
request: Request;
sudoContext?: SudoVerificationResult;
emailVerifiedViaToken?: boolean;
}
export class UserService {
private accountService: UserAccountService;
private authService: UserAuthService;
private relationshipService: UserRelationshipService;
private channelService: UserChannelService;
private contentService: UserContentService;
constructor(
userAccountRepository: IUserAccountRepository,
userSettingsRepository: IUserSettingsRepository,
userAuthRepository: IUserAuthRepository,
userRelationshipRepository: IUserRelationshipRepository,
userChannelRepository: IUserChannelRepository,
userContentRepository: IUserContentRepository,
authService: AuthService,
userCacheService: UserCacheService,
channelService: ChannelService,
channelRepository: IChannelRepository,
guildService: GuildService,
gatewayService: IGatewayService,
entityAssetService: EntityAssetService,
mediaService: IMediaService,
packService: PackService,
emailService: IEmailService,
snowflakeService: SnowflakeService,
discriminatorService: IDiscriminatorService,
rateLimitService: IRateLimitService,
guildRepository: IGuildRepositoryAggregate,
workerService: IWorkerService,
userPermissionUtils: UserPermissionUtils,
kvDeletionQueue: KVAccountDeletionQueueService,
bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService,
botMfaMirrorService: BotMfaMirrorService,
contactChangeLogService: UserContactChangeLogService,
connectionRepository: IConnectionRepository,
limitConfigService: LimitConfigService,
) {
this.accountService = new UserAccountService(
userAccountRepository,
userSettingsRepository,
userRelationshipRepository,
userChannelRepository,
authService,
userCacheService,
guildService,
gatewayService,
entityAssetService,
mediaService,
packService,
emailService,
rateLimitService,
guildRepository,
discriminatorService,
kvDeletionQueue,
contactChangeLogService,
connectionRepository,
limitConfigService,
);
this.authService = new UserAuthService(
userAccountRepository,
userAuthRepository,
authService,
emailService,
gatewayService,
botMfaMirrorService,
);
this.relationshipService = new UserRelationshipService(
userAccountRepository,
userRelationshipRepository,
gatewayService,
userPermissionUtils,
limitConfigService,
);
this.channelService = new UserChannelService(
userAccountRepository,
userChannelRepository,
userRelationshipRepository,
channelService,
channelRepository,
gatewayService,
mediaService,
snowflakeService,
userPermissionUtils,
limitConfigService,
);
this.contentService = new UserContentService(
userAccountRepository,
userContentRepository,
userCacheService,
channelService,
channelRepository,
gatewayService,
mediaService,
workerService,
snowflakeService,
bulkMessageDeletionQueue,
limitConfigService,
);
}
async findUnique(userId: UserID): Promise<User | null> {
return await this.accountService.findUnique(userId);
}
async findUniqueAssert(userId: UserID): Promise<User> {
return await this.accountService.findUniqueAssert(userId);
}
async getUserProfile(params: {
userId: UserID;
targetId: UserID;
guildId?: GuildID;
withMutualFriends?: boolean;
withMutualGuilds?: boolean;
requestCache: RequestCache;
}): Promise<{
user: User;
guildMember?: GuildMemberResponse | null;
guildMemberDomain?: GuildMember | null;
premiumType?: number;
premiumSince?: Date;
premiumLifetimeSequence?: number;
mutualFriends?: Array<User>;
mutualGuilds?: Array<{id: string; nick: string | null}>;
connections?: Array<UserConnectionRow>;
}> {
return await this.accountService.getUserProfile(params);
}
async update(params: UpdateUserParams): Promise<User> {
return await this.accountService.update(params);
}
async generateUniqueDiscriminator(username: string): Promise<number> {
return await this.accountService.generateUniqueDiscriminator(username);
}
async checkUsernameDiscriminatorAvailability(params: {username: string; discriminator: number}): Promise<boolean> {
return await this.accountService.checkUsernameDiscriminatorAvailability(params);
}
async findSettings(userId: UserID): Promise<UserSettings> {
return await this.accountService.findSettings(userId);
}
async updateSettings(params: {userId: UserID; data: UserSettingsUpdateRequest}): Promise<UserSettings> {
return await this.accountService.updateSettings(params);
}
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
return await this.accountService.findGuildSettings(userId, guildId);
}
async updateGuildSettings(params: {
userId: UserID;
guildId: GuildID | null;
data: UserGuildSettingsUpdateRequest;
}): Promise<UserGuildSettings> {
return await this.accountService.updateGuildSettings(params);
}
async getUserNote(params: {userId: UserID; targetId: UserID}): Promise<{note: string} | null> {
return await this.accountService.getUserNote(params);
}
async getUserNotes(userId: UserID): Promise<Record<string, string>> {
return await this.accountService.getUserNotes(userId);
}
async setUserNote(params: {userId: UserID; targetId: UserID; note: string | null}): Promise<void> {
return await this.accountService.setUserNote(params);
}
async selfDisable(userId: UserID): Promise<void> {
return await this.accountService.selfDisable(userId);
}
async selfDelete(userId: UserID): Promise<void> {
return await this.accountService.selfDelete(userId);
}
async resetCurrentUserPremiumState(user: User): Promise<void> {
return await this.accountService.resetCurrentUserPremiumState(user);
}
async dispatchUserUpdate(user: User): Promise<void> {
return await this.accountService.dispatchUserUpdate(user);
}
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
return await this.accountService.dispatchUserSettingsUpdate({userId, settings});
}
async dispatchUserGuildSettingsUpdate({
userId,
settings,
}: {
userId: UserID;
settings: UserGuildSettings;
}): Promise<void> {
return await this.accountService.dispatchUserGuildSettingsUpdate({userId, settings});
}
async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise<void> {
return await this.accountService.dispatchUserNoteUpdate(params);
}
async enableMfaTotp(params: {
user: User;
secret: string;
code: string;
sudoContext: SudoVerificationResult;
}): Promise<Array<MfaBackupCode>> {
return await this.authService.enableMfaTotp(params);
}
async disableMfaTotp(params: {
user: User;
code: string;
sudoContext: SudoVerificationResult;
password?: string;
}): Promise<void> {
return await this.authService.disableMfaTotp(params);
}
async getMfaBackupCodes(params: {
user: User;
regenerate: boolean;
sudoContext: SudoVerificationResult;
password?: string;
}): Promise<Array<MfaBackupCode>> {
return await this.authService.getMfaBackupCodes(params);
}
async regenerateMfaBackupCodes(user: User): Promise<Array<MfaBackupCode>> {
return await this.authService.regenerateMfaBackupCodes(user);
}
async verifyEmail(token: string): Promise<boolean> {
return await this.authService.verifyEmail(token);
}
async resendVerificationEmail(user: User): Promise<boolean> {
return await this.authService.resendVerificationEmail(user);
}
async getRelationships(userId: UserID): Promise<Array<Relationship>> {
return await this.relationshipService.getRelationships(userId);
}
async sendFriendRequestByTag({
userId,
data,
userCacheService,
requestCache,
}: {
userId: UserID;
data: FriendRequestByTagRequest;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
return await this.relationshipService.sendFriendRequestByTag({userId, data, userCacheService, requestCache});
}
async sendFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
return await this.relationshipService.sendFriendRequest({userId, targetId, userCacheService, requestCache});
}
async acceptFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const relationship = await this.relationshipService.acceptFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
});
await this.channelService.ensureDmOpenForBothUsers({
userId,
recipientId: targetId,
userCacheService,
requestCache,
});
return relationship;
}
async blockUser({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
return await this.relationshipService.blockUser({userId, targetId, userCacheService, requestCache});
}
async updateFriendNickname({
userId,
targetId,
nickname,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
nickname: string | null;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
return await this.relationshipService.updateFriendNickname({
userId,
targetId,
nickname,
userCacheService,
requestCache,
});
}
async removeRelationship({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
return await this.relationshipService.removeRelationship({userId, targetId});
}
async dispatchRelationshipCreate({
userId,
relationship,
userCacheService,
requestCache,
}: {
userId: UserID;
relationship: Relationship;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
return await this.relationshipService.dispatchRelationshipCreate({
userId,
relationship,
userCacheService,
requestCache,
});
}
async dispatchRelationshipUpdate({
userId,
relationship,
userCacheService,
requestCache,
}: {
userId: UserID;
relationship: Relationship;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
return await this.relationshipService.dispatchRelationshipUpdate({
userId,
relationship,
userCacheService,
requestCache,
});
}
async dispatchRelationshipRemove({userId, targetId}: {userId: UserID; targetId: string}): Promise<void> {
return await this.relationshipService.dispatchRelationshipRemove({userId, targetId});
}
async getPrivateChannels(userId: UserID): Promise<Array<Channel>> {
return await this.channelService.getPrivateChannels(userId);
}
async createOrOpenDMChannel({
userId,
data,
userCacheService,
requestCache,
}: {
userId: UserID;
data: CreatePrivateChannelRequest;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
return await this.channelService.createOrOpenDMChannel({userId, data, userCacheService, requestCache});
}
async pinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
return await this.channelService.pinDmChannel({userId, channelId});
}
async unpinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
return await this.channelService.unpinDmChannel({userId, channelId});
}
async preloadDMMessages(params: {
userId: UserID;
channelIds: Array<ChannelID>;
}): Promise<Record<string, Message | null>> {
return await this.channelService.preloadDMMessages(params);
}
async getRecentMentions(params: {
userId: UserID;
limit: number;
everyone: boolean;
roles: boolean;
guilds: boolean;
before?: MessageID;
}): Promise<Array<Message>> {
return await this.contentService.getRecentMentions(params);
}
async deleteRecentMention({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
return await this.contentService.deleteRecentMention({userId, messageId});
}
async getSavedMessages({userId, limit}: {userId: UserID; limit: number}): Promise<Array<SavedMessageEntry>> {
return await this.contentService.getSavedMessages({userId, limit});
}
async saveMessage({
userId,
channelId,
messageId,
userCacheService,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
return await this.contentService.saveMessage({userId, channelId, messageId, userCacheService, requestCache});
}
async unsaveMessage({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
return await this.contentService.unsaveMessage({userId, messageId});
}
async registerPushSubscription(params: {
userId: UserID;
endpoint: string;
keys: {p256dh: string; auth: string};
userAgent?: string;
}): Promise<PushSubscription> {
return await this.contentService.registerPushSubscription(params);
}
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
return await this.contentService.listPushSubscriptions(userId);
}
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
return await this.contentService.deletePushSubscription(userId, subscriptionId);
}
async requestDataHarvest(userId: UserID): Promise<{
harvest_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
created_at: string;
}> {
return await this.contentService.requestDataHarvest(userId);
}
async getHarvestStatus(userId: UserID, harvestId: bigint): Promise<UserHarvestResponse> {
return await this.contentService.getHarvestStatus(userId, harvestId);
}
async getLatestHarvest(userId: UserID): Promise<UserHarvestResponse | null> {
return await this.contentService.getLatestHarvest(userId);
}
async getHarvestDownloadUrl(
userId: UserID,
harvestId: bigint,
storageService: IStorageService,
): Promise<{download_url: string; expires_at: string}> {
return await this.contentService.getHarvestDownloadUrl(userId, harvestId, storageService);
}
async requestBulkMessageDeletion(params: {userId: UserID; delayMs?: number}): Promise<void> {
return await this.contentService.requestBulkMessageDeletion(params);
}
async cancelBulkMessageDeletion(userId: UserID): Promise<void> {
return await this.contentService.cancelBulkMessageDeletion(userId);
}
async dispatchRecentMentionDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
return await this.contentService.dispatchRecentMentionDelete({userId, messageId});
}
async dispatchSavedMessageCreate({
userId,
message,
userCacheService,
requestCache,
}: {
userId: UserID;
message: Message;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
return await this.contentService.dispatchSavedMessageCreate({userId, message, userCacheService, requestCache});
}
async dispatchSavedMessageDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
return await this.contentService.dispatchSavedMessageDelete({userId, messageId});
}
}