refactor progress
This commit is contained in:
56
packages/api/src/user/services/BaseUserUpdatePropagator.tsx
Normal file
56
packages/api/src/user/services/BaseUserUpdatePropagator.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
112
packages/api/src/user/services/CustomStatusValidator.tsx
Normal file
112
packages/api/src/user/services/CustomStatusValidator.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
411
packages/api/src/user/services/EmailChangeService.tsx
Normal file
411
packages/api/src/user/services/EmailChangeService.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
228
packages/api/src/user/services/PasswordChangeService.tsx
Normal file
228
packages/api/src/user/services/PasswordChangeService.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
116
packages/api/src/user/services/UserAccountLifecycleService.tsx
Normal file
116
packages/api/src/user/services/UserAccountLifecycleService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
310
packages/api/src/user/services/UserAccountLookupService.tsx
Normal file
310
packages/api/src/user/services/UserAccountLookupService.tsx
Normal 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()));
|
||||
}
|
||||
}
|
||||
59
packages/api/src/user/services/UserAccountNotesService.tsx
Normal file
59
packages/api/src/user/services/UserAccountNotesService.tsx
Normal 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 ?? ''});
|
||||
}
|
||||
}
|
||||
481
packages/api/src/user/services/UserAccountProfileService.tsx
Normal file
481
packages/api/src/user/services/UserAccountProfileService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
324
packages/api/src/user/services/UserAccountRequestService.tsx
Normal file
324
packages/api/src/user/services/UserAccountRequestService.tsx
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
305
packages/api/src/user/services/UserAccountSecurityService.tsx
Normal file
305
packages/api/src/user/services/UserAccountSecurityService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
353
packages/api/src/user/services/UserAccountService.tsx
Normal file
353
packages/api/src/user/services/UserAccountService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
339
packages/api/src/user/services/UserAccountSettingsService.tsx
Normal file
339
packages/api/src/user/services/UserAccountSettingsService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
});
|
||||
}
|
||||
}
|
||||
204
packages/api/src/user/services/UserAuthRequestService.tsx
Normal file
204
packages/api/src/user/services/UserAuthRequestService.tsx
Normal 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
207
packages/api/src/user/services/UserAuthService.tsx
Normal file
207
packages/api/src/user/services/UserAuthService.tsx
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
86
packages/api/src/user/services/UserChannelRequestService.tsx
Normal file
86
packages/api/src/user/services/UserChannelRequestService.tsx
Normal 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});
|
||||
}
|
||||
}
|
||||
547
packages/api/src/user/services/UserChannelService.tsx
Normal file
547
packages/api/src/user/services/UserChannelService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
118
packages/api/src/user/services/UserContactChangeLogService.tsx
Normal file
118
packages/api/src/user/services/UserContactChangeLogService.tsx
Normal 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}`;
|
||||
}
|
||||
}
|
||||
181
packages/api/src/user/services/UserContentRequestService.tsx
Normal file
181
packages/api/src/user/services/UserContentRequestService.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
482
packages/api/src/user/services/UserContentService.tsx
Normal file
482
packages/api/src/user/services/UserContentService.tsx
Normal 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()},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
519
packages/api/src/user/services/UserDeletionService.tsx
Normal file
519
packages/api/src/user/services/UserDeletionService.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
554
packages/api/src/user/services/UserRelationshipService.tsx
Normal file
554
packages/api/src/user/services/UserRelationshipService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
622
packages/api/src/user/services/UserService.tsx
Normal file
622
packages/api/src/user/services/UserService.tsx
Normal 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});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user