refactor progress

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

View File

@@ -0,0 +1,22 @@
/*
* 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 {IUserRepositoryAggregate} from '@fluxer/api/src/user/repositories/IUserRepositoryAggregate';
export interface IUserRepository extends IUserRepositoryAggregate {}

View File

@@ -0,0 +1,65 @@
/*
* 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 {User} from '@fluxer/api/src/models/User';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
export async function getCachedUserPartialResponse(params: {
userId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<UserPartialResponse> {
const {userId, userCacheService, requestCache} = params;
return await userCacheService.getUserPartialResponse(userId, requestCache);
}
export async function getCachedUserPartialResponses(params: {
userIds: Array<UserID>;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Map<UserID, UserPartialResponse>> {
const {userIds, userCacheService, requestCache} = params;
return await userCacheService.getUserPartialResponses(userIds, requestCache);
}
export async function mapUserToPartialResponseWithCache(params: {
user: User;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<UserPartialResponse> {
const {user, userCacheService, requestCache} = params;
const cached = requestCache.userPartials.get(user.id);
if (cached) {
return cached;
}
return userCacheService.setUserPartialResponseFromUserInBackground(user, requestCache);
}
export async function invalidateUserCache(params: {userId: UserID; userCacheService: UserCacheService}): Promise<void> {
const {userId, userCacheService} = params;
await userCacheService.invalidateUserCache(userId);
}
export async function updateUserCache(params: {user: User; userCacheService: UserCacheService}): Promise<void> {
const {user, userCacheService} = params;
await userCacheService.setUserPartialResponseFromUser(user);
}

View File

@@ -0,0 +1,109 @@
/*
* 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 {UserHarvestRow} from '@fluxer/api/src/database/types/UserTypes';
import type {HarvestStatusResponseSchema} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
import type {z} from 'zod';
export class UserHarvest {
userId: UserID;
harvestId: bigint;
requestedAt: Date;
startedAt: Date | null;
completedAt: Date | null;
failedAt: Date | null;
storageKey: string | null;
fileSize: bigint | null;
progressPercent: number;
progressStep: string | null;
errorMessage: string | null;
downloadUrlExpiresAt: Date | null;
constructor(row: UserHarvestRow) {
this.userId = row.user_id;
this.harvestId = row.harvest_id;
this.requestedAt = row.requested_at;
this.startedAt = row.started_at ?? null;
this.completedAt = row.completed_at ?? null;
this.failedAt = row.failed_at ?? null;
this.storageKey = row.storage_key ?? null;
this.fileSize = row.file_size ?? null;
this.progressPercent = row.progress_percent;
this.progressStep = row.progress_step ?? null;
this.errorMessage = row.error_message ?? null;
this.downloadUrlExpiresAt = row.download_url_expires_at ?? null;
}
toRow(): UserHarvestRow {
return {
user_id: this.userId,
harvest_id: this.harvestId,
requested_at: this.requestedAt,
started_at: this.startedAt,
completed_at: this.completedAt,
failed_at: this.failedAt,
storage_key: this.storageKey,
file_size: this.fileSize,
progress_percent: this.progressPercent,
progress_step: this.progressStep,
error_message: this.errorMessage,
download_url_expires_at: this.downloadUrlExpiresAt,
};
}
toResponse(): {
harvest_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
created_at: string;
started_at: string | null;
completed_at: string | null;
failed_at: string | null;
file_size: string | null;
progress_percent: number;
progress_step: string | null;
error_message: string | null;
download_url_expires_at: string | null;
expires_at: string | null;
} {
return {
harvest_id: this.harvestId.toString(),
status: this.getStatus(),
created_at: this.requestedAt.toISOString(),
started_at: this.startedAt?.toISOString() ?? null,
completed_at: this.completedAt?.toISOString() ?? null,
failed_at: this.failedAt?.toISOString() ?? null,
file_size: this.fileSize?.toString() ?? null,
progress_percent: this.progressPercent,
progress_step: this.progressStep,
error_message: this.errorMessage,
download_url_expires_at: this.downloadUrlExpiresAt?.toISOString() ?? null,
expires_at: this.downloadUrlExpiresAt?.toISOString() ?? null,
};
}
getStatus(): 'pending' | 'processing' | 'completed' | 'failed' {
if (this.failedAt) return 'failed';
if (this.completedAt) return 'completed';
if (this.startedAt) return 'processing';
return 'pending';
}
}
export type UserHarvestResponse = z.infer<typeof HarvestStatusResponseSchema>;

View File

@@ -0,0 +1,158 @@
/*
* 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 {Db, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {UserHarvestRow} from '@fluxer/api/src/database/types/UserTypes';
import {Logger} from '@fluxer/api/src/Logger';
import {UserHarvests} from '@fluxer/api/src/Tables';
import {UserHarvest} from '@fluxer/api/src/user/UserHarvestModel';
const FIND_HARVEST_CQL = UserHarvests.selectCql({
where: [UserHarvests.where.eq('user_id'), UserHarvests.where.eq('harvest_id')],
});
const createFindUserHarvestsQuery = (limit: number) =>
UserHarvests.selectCql({
where: UserHarvests.where.eq('user_id'),
limit,
});
const FIND_LATEST_HARVEST_CQL = UserHarvests.selectCql({
where: UserHarvests.where.eq('user_id'),
limit: 1,
});
export class UserHarvestRepository {
async create(harvest: UserHarvest): Promise<void> {
await upsertOne(UserHarvests.upsertAll(harvest.toRow()));
Logger.debug({userId: harvest.userId, harvestId: harvest.harvestId}, 'Created harvest record');
}
async update(harvest: UserHarvest): Promise<void> {
const row = harvest.toRow();
await upsertOne(
UserHarvests.patchByPk(
{user_id: row.user_id, harvest_id: row.harvest_id},
{
started_at: Db.set(row.started_at),
completed_at: Db.set(row.completed_at),
failed_at: Db.set(row.failed_at),
storage_key: Db.set(row.storage_key),
file_size: Db.set(row.file_size),
progress_percent: Db.set(row.progress_percent),
progress_step: Db.set(row.progress_step),
error_message: Db.set(row.error_message),
download_url_expires_at: Db.set(row.download_url_expires_at),
},
),
);
Logger.debug({userId: harvest.userId, harvestId: harvest.harvestId}, 'Updated harvest record');
}
async findByUserAndHarvestId(userId: UserID, harvestId: bigint): Promise<UserHarvest | null> {
const row = await fetchOne<UserHarvestRow>(FIND_HARVEST_CQL, {
user_id: userId,
harvest_id: harvestId,
});
return row ? new UserHarvest(row) : null;
}
async findByUserId(userId: UserID, limit: number = 10): Promise<Array<UserHarvest>> {
const rows = await fetchMany<UserHarvestRow>(createFindUserHarvestsQuery(limit), {
user_id: userId,
});
return rows.map((row) => new UserHarvest(row));
}
async findLatestByUserId(userId: UserID): Promise<UserHarvest | null> {
const row = await fetchOne<UserHarvestRow>(FIND_LATEST_HARVEST_CQL, {
user_id: userId,
});
return row ? new UserHarvest(row) : null;
}
async updateProgress(
userId: UserID,
harvestId: bigint,
progressPercent: number,
progressStep: string,
): Promise<void> {
await upsertOne(
UserHarvests.patchByPk(
{user_id: userId, harvest_id: harvestId},
{
progress_percent: Db.set(progressPercent),
progress_step: Db.set(progressStep),
},
),
);
Logger.debug({userId, harvestId, progressPercent, progressStep}, 'Updated harvest progress');
}
async markAsStarted(userId: UserID, harvestId: bigint): Promise<void> {
await upsertOne(
UserHarvests.patchByPk(
{user_id: userId, harvest_id: harvestId},
{
started_at: Db.set(new Date()),
progress_percent: Db.set(0),
progress_step: Db.set('Starting harvest'),
},
),
);
Logger.debug({userId, harvestId}, 'Marked harvest as started');
}
async markAsCompleted(
userId: UserID,
harvestId: bigint,
storageKey: string,
fileSize: bigint,
downloadUrlExpiresAt: Date,
): Promise<void> {
await upsertOne(
UserHarvests.patchByPk(
{user_id: userId, harvest_id: harvestId},
{
completed_at: Db.set(new Date()),
storage_key: Db.set(storageKey),
file_size: Db.set(fileSize),
download_url_expires_at: Db.set(downloadUrlExpiresAt),
progress_percent: Db.set(100),
progress_step: Db.set('Completed'),
},
),
);
Logger.debug({userId, harvestId, storageKey, fileSize}, 'Marked harvest as completed');
}
async markAsFailed(userId: UserID, harvestId: bigint, errorMessage: string): Promise<void> {
await upsertOne(
UserHarvests.patchByPk(
{user_id: userId, harvest_id: harvestId},
{
failed_at: Db.set(new Date()),
error_message: Db.set(errorMessage),
},
),
);
Logger.error({userId, harvestId, errorMessage}, 'Marked harvest as failed');
}
}

View File

@@ -0,0 +1,87 @@
/*
* 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 {Config} from '@fluxer/api/src/Config';
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ms} from 'itty-time';
interface PremiumCheckable {
premiumType: number | null;
premiumUntil: Date | null;
premiumWillCancel: boolean;
flags: bigint;
}
export function checkIsPremium(user: PremiumCheckable): boolean {
if (Config.instance.selfHosted) {
return true;
}
if ((user.flags & UserFlags.PREMIUM_ENABLED_OVERRIDE) !== 0n) {
return true;
}
if (user.premiumType == null || user.premiumType <= 0) {
return false;
}
if (user.premiumUntil == null) {
return true;
}
const nowMs = Date.now();
const untilMs = user.premiumUntil.getTime();
if (user.premiumWillCancel) {
return nowMs <= untilMs;
}
return nowMs <= untilMs + ms('3 days');
}
export const PREMIUM_CLEAR_FIELDS = [
'premium_type',
'premium_since',
'premium_until',
'premium_will_cancel',
'premium_billing_cycle',
] as const;
export type PremiumClearField = (typeof PREMIUM_CLEAR_FIELDS)[number];
export function shouldStripExpiredPremium(user: PremiumCheckable): boolean {
if ((user.premiumType ?? 0) <= 0) {
return false;
}
return !checkIsPremium(user);
}
export function mapExpiredPremiumFields<T>(mapper: (field: PremiumClearField) => T): Record<PremiumClearField, T> {
const result = {} as Record<PremiumClearField, T>;
for (const field of PREMIUM_CLEAR_FIELDS) {
result[field] = mapper(field);
}
return result;
}
export function createPremiumClearPatch(): Partial<UserRow> {
return mapExpiredPremiumFields(() => null) as Partial<UserRow>;
}

View File

@@ -0,0 +1,446 @@
/*
* 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 {createGuildID, type GuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
import {getGlobalLimitConfigSnapshot} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {GuildChannelOverride} from '@fluxer/api/src/models/GuildChannelOverride';
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
import type {MuteConfiguration} from '@fluxer/api/src/models/MuteConfiguration';
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 {isUserAdult} from '@fluxer/api/src/utils/AgeUtils';
import {
DEFAULT_GUILD_FOLDER_ICON,
DELETED_USER_DISCRIMINATOR,
DELETED_USER_GLOBAL_NAME,
DELETED_USER_USERNAME,
type GuildFolderIcon,
PUBLIC_USER_FLAGS,
SuspiciousActivityFlags,
UNCATEGORIZED_FOLDER_ID,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
import type {
RelationshipResponse,
UserGuildSettingsResponse,
UserPartialResponse,
UserPrivateResponse,
UserProfileResponse,
UserSettingsResponse,
} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
const PUBLIC_USER_FLAGS_WITHOUT_STAFF = PUBLIC_USER_FLAGS & ~UserFlags.STAFF;
function getVisiblePublicUserFlags(userFlags: bigint): bigint {
return (userFlags & UserFlags.STAFF_HIDDEN) !== 0n ? PUBLIC_USER_FLAGS_WITHOUT_STAFF : PUBLIC_USER_FLAGS;
}
function mapUserFlagsToPublicBitfield(user: User): number {
const flags = user.flags ?? 0n;
return Number(flags & getVisiblePublicUserFlags(flags));
}
export function mapUserToPartialResponse(user: User): UserPartialResponse {
const isBot = user.isBot;
let avatarHash = user.avatarHash;
const snapshot = getGlobalLimitConfigSnapshot();
if (avatarHash?.startsWith('a_')) {
if (snapshot != null) {
const ctx = createLimitMatchContext({user});
const hasAnimatedAvatar = resolveLimitSafe(snapshot, ctx, 'feature_animated_avatar', 0);
if (hasAnimatedAvatar === 0 && !isBot) {
avatarHash = avatarHash.substring(2);
}
}
}
const isDeleted = (user.flags & UserFlags.DELETED) !== 0n && !user.isSystem;
if (isDeleted) {
return {
id: user.id.toString(),
username: DELETED_USER_USERNAME,
discriminator: DELETED_USER_DISCRIMINATOR.toString().padStart(4, '0'),
global_name: DELETED_USER_GLOBAL_NAME,
avatar: null,
avatar_color: null,
bot: isBot || undefined,
system: user.isSystem || undefined,
flags: 0,
};
}
return {
id: user.id.toString(),
username: user.username,
discriminator: user.discriminator.toString().padStart(4, '0'),
global_name: user.globalName,
avatar: avatarHash,
avatar_color: user.avatarColor,
bot: isBot || undefined,
system: user.isSystem || undefined,
flags: mapUserFlagsToPublicBitfield(user),
};
}
export function hasPartialUserFieldsChanged(oldUser: User, newUser: User): boolean {
const oldPartial = mapUserToPartialResponse(oldUser);
const newPartial = mapUserToPartialResponse(newUser);
return (
oldPartial.username !== newPartial.username ||
oldPartial.discriminator !== newPartial.discriminator ||
oldPartial.global_name !== newPartial.global_name ||
oldPartial.avatar !== newPartial.avatar ||
oldPartial.avatar_color !== newPartial.avatar_color ||
oldPartial.bot !== newPartial.bot ||
oldPartial.system !== newPartial.system ||
oldPartial.flags !== newPartial.flags
);
}
export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
const snapshot = getGlobalLimitConfigSnapshot();
const isStaff = (user.flags & UserFlags.STAFF) !== 0n;
const partialResponse = mapUserToPartialResponse(user);
const ctx = createLimitMatchContext({user});
const hasAnimatedBanner = resolveLimitSafe(snapshot, ctx, 'feature_animated_banner', 0);
const isActuallyPremium = user.isPremium();
const traitSet = new Set<string>();
for (const trait of user.traits ?? []) {
if (trait && trait !== 'premium') {
traitSet.add(trait);
}
}
if (isActuallyPremium) {
traitSet.add('premium');
}
let requiredActions: Array<string> | undefined;
if (user.suspiciousActivityFlags != null && user.suspiciousActivityFlags > 0) {
const actions: Array<string> = [];
for (const [key, value] of Object.entries(SuspiciousActivityFlags)) {
if (user.suspiciousActivityFlags & value) {
actions.push(key);
}
}
if (actions.length > 0) {
requiredActions = actions;
}
}
const traits = Array.from(traitSet).sort();
return {
...partialResponse,
flags: Number((user.flags ?? 0n) & PUBLIC_USER_FLAGS_WITHOUT_STAFF),
is_staff: isStaff,
acls: Array.from(user.acls),
traits,
email: user.email ?? null,
email_bounced: user.emailBounced,
phone: user.phone ?? null,
bio: user.bio,
pronouns: user.pronouns,
accent_color: user.accentColor,
banner: hasAnimatedBanner > 0 ? user.bannerHash : null,
banner_color: hasAnimatedBanner > 0 ? user.bannerColor : null,
mfa_enabled: (user.authenticatorTypes?.size ?? 0) > 0,
authenticator_types: user.authenticatorTypes ? Array.from(user.authenticatorTypes) : undefined,
verified: user.emailVerified,
premium_type: isActuallyPremium ? user.premiumType : 0,
premium_since: isActuallyPremium ? (user.premiumSince?.toISOString() ?? null) : null,
premium_until: user.premiumUntil?.toISOString() ?? null,
premium_will_cancel: user.premiumWillCancel ?? false,
premium_billing_cycle: user.premiumBillingCycle || null,
premium_lifetime_sequence: user.premiumLifetimeSequence ?? null,
premium_badge_hidden: !!(user.flags & UserFlags.PREMIUM_BADGE_HIDDEN),
premium_badge_masked: !!(user.flags & UserFlags.PREMIUM_BADGE_MASKED),
premium_badge_timestamp_hidden: !!(user.flags & UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN),
premium_badge_sequence_hidden: !!(user.flags & UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN),
premium_purchase_disabled: !!(user.flags & UserFlags.PREMIUM_PURCHASE_DISABLED),
premium_enabled_override: !!(user.flags & UserFlags.PREMIUM_ENABLED_OVERRIDE),
password_last_changed_at: user.passwordLastChangedAt?.toISOString() ?? null,
required_actions: requiredActions ?? null,
nsfw_allowed: isUserAdult(user.dateOfBirth),
has_dismissed_premium_onboarding:
user.premiumSince != null &&
user.premiumOnboardingDismissedAt != null &&
user.premiumOnboardingDismissedAt >= user.premiumSince,
has_ever_purchased: user.hasEverPurchased,
has_unread_gift_inventory:
user.giftInventoryServerSeq != null &&
(user.giftInventoryClientSeq == null || user.giftInventoryClientSeq < user.giftInventoryServerSeq),
unread_gift_inventory_count:
user.giftInventoryServerSeq != null ? user.giftInventoryServerSeq - (user.giftInventoryClientSeq ?? 0) : 0,
used_mobile_client: !!(user.flags & UserFlags.USED_MOBILE_CLIENT),
pending_bulk_message_deletion:
user.pendingBulkMessageDeletionAt != null
? {
scheduled_at: user.pendingBulkMessageDeletionAt.toISOString(),
channel_count: user.pendingBulkMessageDeletionChannelCount ?? 0,
message_count: user.pendingBulkMessageDeletionMessageCount ?? 0,
}
: null,
};
}
export function mapUserToProfileResponse(user: User): UserProfileResponse {
const snapshot = getGlobalLimitConfigSnapshot();
const ctx = createLimitMatchContext({user});
const hasAnimatedBanner = resolveLimitSafe(snapshot, ctx, 'feature_animated_banner', 0);
return {
bio: user.bio,
pronouns: user.pronouns,
banner: hasAnimatedBanner > 0 ? user.bannerHash : null,
banner_color: hasAnimatedBanner > 0 ? user.bannerColor : null,
accent_color: user.accentColor,
};
}
export function mapUserToOAuthResponse(user: User, opts?: {includeEmail?: boolean}) {
const includeEmail = opts?.includeEmail && !!user.email;
return {
sub: user.id.toString(),
id: user.id.toString(),
username: user.username,
discriminator: user.discriminator.toString().padStart(4, '0'),
avatar: user.avatarHash,
verified: includeEmail ? (user.emailVerified ?? false) : undefined,
email: includeEmail ? (user.email ?? null) : null,
flags: mapUserFlagsToPublicBitfield(user),
global_name: user.globalName ?? null,
bot: user.isBot || false,
system: user.isSystem || false,
acls: Array.from(user.acls),
avatar_color: user.avatarColor,
};
}
export function mapGuildMemberToProfileResponse(
guildMember: GuildMember | null | undefined,
): UserProfileResponse | null {
if (!guildMember) return null;
return {
bio: guildMember.bio,
pronouns: guildMember.pronouns,
banner: guildMember.bannerHash,
accent_color: guildMember.accentColor,
};
}
interface GuildFolderResponse {
id: number;
name: string | null;
color: number | null;
flags: number;
icon: GuildFolderIcon;
guild_ids: Array<string>;
}
function reconcileGuildFolders(
folders: Array<GuildFolderResponse>,
memberGuildIds: ReadonlyArray<GuildID>,
): Array<GuildFolderResponse> {
const memberSet = new Set(memberGuildIds.map(String));
const seenGuildIds = new Set<string>();
const reconciledFolders = folders.map((folder) => ({
...folder,
guild_ids: folder.guild_ids.filter((guildId) => {
if (!memberSet.has(guildId)) {
return false;
}
if (seenGuildIds.has(guildId)) {
return false;
}
seenGuildIds.add(guildId);
return true;
}),
}));
const missingGuildIds = memberGuildIds.filter((id) => !seenGuildIds.has(String(id))).map(String);
if (missingGuildIds.length > 0) {
const uncategorizedIndex = reconciledFolders.findIndex((f) => f.id === UNCATEGORIZED_FOLDER_ID);
if (uncategorizedIndex === -1) {
reconciledFolders.push({
id: UNCATEGORIZED_FOLDER_ID,
name: null,
color: null,
flags: 0,
icon: DEFAULT_GUILD_FOLDER_ICON,
guild_ids: missingGuildIds,
});
} else {
reconciledFolders[uncategorizedIndex].guild_ids = [
...reconciledFolders[uncategorizedIndex].guild_ids,
...missingGuildIds,
];
}
}
return reconciledFolders.filter((f) => f.guild_ids.length > 0);
}
export function mapUserSettingsToResponse(params: {
settings: UserSettings;
memberGuildIds?: ReadonlyArray<GuildID>;
}): UserSettingsResponse {
const {settings, memberGuildIds} = params;
let guildFolders: Array<GuildFolderResponse>;
if (settings.guildFolders != null && settings.guildFolders.length > 0) {
guildFolders = settings.guildFolders.map((folder) => ({
id: folder.folderId,
name: folder.name,
color: folder.color,
flags: folder.flags,
icon: folder.icon,
guild_ids: folder.guildIds.map(String),
}));
} else if (settings.guildPositions != null && settings.guildPositions.length > 0) {
guildFolders = [
{
id: UNCATEGORIZED_FOLDER_ID,
name: null,
color: null,
flags: 0,
icon: DEFAULT_GUILD_FOLDER_ICON,
guild_ids: settings.guildPositions.map(String),
},
];
} else {
guildFolders = [];
}
if (memberGuildIds != null) {
guildFolders = reconcileGuildFolders(guildFolders, memberGuildIds);
}
return {
status: settings.status,
status_resets_at: settings.statusResetsAt?.toISOString() ?? null,
status_resets_to: settings.statusResetsTo,
theme: settings.theme,
locale: settings.locale,
restricted_guilds: [...settings.restrictedGuilds].map(String),
bot_restricted_guilds: [...settings.botRestrictedGuilds].map(String),
default_guilds_restricted: settings.defaultGuildsRestricted,
bot_default_guilds_restricted: settings.botDefaultGuildsRestricted,
inline_attachment_media: settings.inlineAttachmentMedia,
inline_embed_media: settings.inlineEmbedMedia,
gif_auto_play: settings.gifAutoPlay,
render_embeds: settings.renderEmbeds,
render_reactions: settings.renderReactions,
animate_emoji: settings.animateEmoji,
animate_stickers: settings.animateStickers,
render_spoilers: settings.renderSpoilers,
message_display_compact: settings.compactMessageDisplay,
friend_source_flags: settings.friendSourceFlags,
incoming_call_flags: settings.incomingCallFlags,
group_dm_add_permission_flags: settings.groupDmAddPermissionFlags,
guild_folders: guildFolders,
custom_status: settings.customStatus
? {
text: settings.customStatus.text,
expires_at: settings.customStatus.expiresAt?.toISOString(),
emoji_id: settings.customStatus.emojiId?.toString(),
emoji_name: settings.customStatus.emojiName,
emoji_animated: settings.customStatus.emojiAnimated,
}
: null,
afk_timeout: settings.afkTimeout,
time_format: settings.timeFormat,
developer_mode: settings.developerMode,
trusted_domains: [...settings.trustedDomains],
default_hide_muted_channels: settings.defaultHideMutedChannels,
};
}
export async function mapRelationshipToResponse(params: {
relationship: Relationship;
userPartialResolver: (userId: UserID) => Promise<UserPartialResponse>;
}): Promise<RelationshipResponse> {
const {relationship, userPartialResolver} = params;
const userPartial = await userPartialResolver(relationship.targetUserId);
return {
id: relationship.targetUserId.toString(),
type: relationship.type,
user: userPartial,
since: relationship.since?.toISOString(),
nickname: relationship.nickname,
};
}
const mapMuteConfigToResponse = (
muteConfig: MuteConfiguration | null,
): {end_time: string | null; selected_time_window: number} | null =>
muteConfig
? {
end_time: muteConfig.endTime?.toISOString() ?? null,
selected_time_window: muteConfig.selectedTimeWindow ?? 0,
}
: null;
function mapChannelOverrideToResponse(override: GuildChannelOverride): {
collapsed: boolean;
message_notifications: number;
muted: boolean;
mute_config: {end_time: string | null; selected_time_window: number} | null;
} {
return {
collapsed: override.collapsed,
message_notifications: override.messageNotifications ?? 0,
muted: override.muted,
mute_config: mapMuteConfigToResponse(override.muteConfig),
};
}
export function mapUserGuildSettingsToResponse(settings: UserGuildSettings): UserGuildSettingsResponse {
return {
guild_id: settings.guildId === createGuildID(0n) ? null : settings.guildId.toString(),
message_notifications: settings.messageNotifications ?? 0,
muted: settings.muted,
mute_config: mapMuteConfigToResponse(settings.muteConfig),
mobile_push: settings.mobilePush,
suppress_everyone: settings.suppressEveryone,
suppress_roles: settings.suppressRoles,
hide_muted_channels: settings.hideMutedChannels,
channel_overrides: settings.channelOverrides.size
? Object.fromEntries(
Array.from(settings.channelOverrides.entries()).map(([channelId, override]) => [
channelId.toString(),
mapChannelOverrideToResponse(override),
]),
)
: null,
version: settings.version,
};
}

View File

@@ -0,0 +1,161 @@
/*
* 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 {MAX_GUILDS_PREMIUM} from '@fluxer/constants/src/LimitConstants';
import {Locales} from '@fluxer/constants/src/Locales';
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
import {
DEFAULT_GUILD_FOLDER_ICON,
GuildFolderIcons,
ThemeTypes,
UserNotificationSettings,
} from '@fluxer/constants/src/UserConstants';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
import {CustomStatusPayload} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import {DateTimeType} from '@fluxer/schema/src/primitives/QueryValidators';
import {ColorType, createStringType, Int32Type, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
const SettableStatusTypes = {
ONLINE: StatusTypes.ONLINE,
DND: StatusTypes.DND,
IDLE: StatusTypes.IDLE,
INVISIBLE: StatusTypes.INVISIBLE,
} as const;
const StatusTypeValues = Object.values(SettableStatusTypes) as Array<ValueOf<typeof SettableStatusTypes>>;
const ThemeTypeValues = Object.values(ThemeTypes) as Array<ValueOf<typeof ThemeTypes>>;
const LocaleValues = Object.values(Locales) as Array<ValueOf<typeof Locales>>;
const GuildFolderIconValues = Object.values(GuildFolderIcons) as Array<ValueOf<typeof GuildFolderIcons>>;
const StatusTypeSchema = z.enum(
StatusTypeValues as [ValueOf<typeof SettableStatusTypes>, ...Array<ValueOf<typeof SettableStatusTypes>>],
);
const ThemeTypeSchema = z.enum(ThemeTypeValues as [ValueOf<typeof ThemeTypes>, ...Array<ValueOf<typeof ThemeTypes>>]);
const LocaleSchema = z.enum(LocaleValues as [ValueOf<typeof Locales>, ...Array<ValueOf<typeof Locales>>]);
const GuildFolderIconSchema = z.enum(
GuildFolderIconValues as [ValueOf<typeof GuildFolderIcons>, ...Array<ValueOf<typeof GuildFolderIcons>>],
);
const MessageNotificationsType = z.union([
z.literal(UserNotificationSettings.ALL_MESSAGES),
z.literal(UserNotificationSettings.ONLY_MENTIONS),
z.literal(UserNotificationSettings.NO_MESSAGES),
z.literal(UserNotificationSettings.INHERIT),
]);
const MuteConfigType = z
.object({
end_time: DateTimeType.nullish().describe('When the mute expires'),
selected_time_window: z.number().int().describe('Duration of mute in seconds'),
})
.nullish();
const ChannelOverrideType = z.object({
collapsed: z.boolean().describe('Whether the channel is collapsed in the sidebar'),
message_notifications: MessageNotificationsType.describe('Notification setting for this channel'),
muted: z.boolean().describe('Whether notifications are muted for this channel'),
mute_config: MuteConfigType.describe('Configuration for temporary mute'),
});
export const UserSettingsUpdateRequest = z
.object({
flags: z.number().int().describe('Bitfield of user settings flags'),
status: StatusTypeSchema.describe('Current online status (online, idle, dnd, invisible)'),
status_resets_at: DateTimeType.nullish().describe('When the status should reset'),
status_resets_to: StatusTypeSchema.nullish().describe('Status to reset to after timer'),
theme: ThemeTypeSchema.describe('UI theme preference (dark or light)'),
guild_positions: z
.array(SnowflakeType)
.transform((ids) => [...new Set(ids)])
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`)
.describe('Ordered array of guild IDs for sidebar positioning'),
locale: LocaleSchema.describe('User language/locale preference'),
restricted_guilds: z
.array(SnowflakeType)
.transform((ids) => [...new Set(ids)])
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`)
.describe('Guild IDs where DMs from members are restricted'),
bot_restricted_guilds: z
.array(SnowflakeType)
.transform((ids) => [...new Set(ids)])
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`)
.describe('Guild IDs where DMs from bots are restricted'),
default_guilds_restricted: z.boolean().describe('Default DM restriction for new guilds'),
bot_default_guilds_restricted: z.boolean().describe('Default bot DM restriction for new guilds'),
inline_attachment_media: z.boolean().describe('Auto-display images and videos inline'),
inline_embed_media: z.boolean().describe('Auto-display embedded media inline'),
gif_auto_play: z.boolean().describe('Auto-play GIFs when visible'),
render_embeds: z.boolean().describe('Show link embeds in messages'),
render_reactions: z.boolean().describe('Show reactions on messages'),
animate_emoji: z.boolean().describe('Animate custom emoji'),
animate_stickers: z
.number()
.int()
.min(0)
.max(2)
.describe('Sticker animation setting (0=never, 1=on hover, 2=always)'),
render_spoilers: z
.number()
.int()
.min(0)
.max(2)
.describe('Spoiler display setting (0=hidden, 1=on hover, 2=always)'),
message_display_compact: z.boolean().describe('Use compact message display mode'),
friend_source_flags: Int32Type.describe('Bitfield for friend request source permissions'),
incoming_call_flags: Int32Type.describe('Bitfield for incoming call permissions'),
group_dm_add_permission_flags: Int32Type.describe('Bitfield for group DM add permissions'),
guild_folders: z
.array(
z.object({
id: z.number().int().min(-1).describe('Unique folder identifier (-1 for uncategorized)'),
name: createStringType(0, 32).nullish().describe('Folder display name'),
color: ColorType.nullish().default(0x000000).describe('Folder color as integer'),
flags: Int32Type.default(0).describe('Bitfield for guild folder display behaviour'),
icon: GuildFolderIconSchema.default(DEFAULT_GUILD_FOLDER_ICON).describe('Selected icon for the guild folder'),
guild_ids: z
.array(SnowflakeType)
.transform((ids) => [...new Set(ids)])
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`)
.describe('Guild IDs contained in this folder'),
}),
)
.max(100)
.describe('Array of guild folder configurations'),
custom_status: CustomStatusPayload.nullish().describe('Custom status with text and emoji'),
afk_timeout: z.number().int().min(60).max(600).describe('AFK timeout in seconds (60-600)'),
time_format: z.number().int().min(0).max(2).describe('Time format preference (0=12h, 1=24h, 2=relative)'),
developer_mode: z.boolean().describe('Enable developer mode features'),
})
.partial();
export const UserGuildSettingsUpdateRequest = z
.object({
message_notifications: MessageNotificationsType.describe('Default notification level for the guild'),
muted: z.boolean().describe('Whether the guild is muted'),
mute_config: MuteConfigType.describe('Configuration for temporary mute'),
mobile_push: z.boolean().describe('Whether to send mobile push notifications'),
suppress_everyone: z.boolean().describe('Suppress @everyone and @here mentions'),
suppress_roles: z.boolean().describe('Suppress role mentions'),
hide_muted_channels: z.boolean().describe('Hide muted channels from sidebar'),
channel_overrides: z
.record(
SnowflakeType.transform((value) => value.toString()),
ChannelOverrideType,
)
.nullish()
.describe('Per-channel notification settings overrides'),
})
.partial();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,511 @@
/*
* 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 {requireSudoMode} from '@fluxer/api/src/auth/services/SudoVerificationService';
import {DefaultUserOnly, LoginRequired, LoginRequiredAllowSuspicious} from '@fluxer/api/src/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
import {SudoModeMiddleware} from '@fluxer/api/src/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
import {Validator} from '@fluxer/api/src/Validator';
import {
DisableTotpRequest,
EnableMfaTotpRequest,
MfaBackupCodesRequest,
MfaBackupCodesResponse,
PhoneAddRequest,
PhoneSendVerificationRequest,
PhoneVerifyRequest,
PhoneVerifyResponse,
SudoMfaMethodsResponse,
SudoVerificationSchema,
WebAuthnChallengeResponse,
WebAuthnCredentialListResponse,
WebAuthnCredentialUpdateRequest,
WebAuthnRegisterRequest,
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {CredentialIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
export function UserAuthController(app: HonoApp) {
app.post(
'/users/@me/mfa/totp/enable',
RateLimitMiddleware(RateLimitConfigs.USER_MFA_TOTP_ENABLE),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', EnableMfaTotpRequest),
OpenAPI({
operationId: 'enable_totp_mfa',
summary: 'Enable TOTP multi-factor authentication',
responseSchema: MfaBackupCodesResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Enable time-based one-time password (TOTP) MFA on the current account. Returns backup codes for account recovery. Requires sudo mode verification.',
}),
async (ctx) => {
const body = ctx.req.valid('json');
const user = ctx.get('user');
const sudoResult = await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
return ctx.json(
await ctx.get('userAuthRequestService').enableTotp({
user,
data: body,
sudoContext: sudoResult,
}),
);
},
);
app.post(
'/users/@me/mfa/totp/disable',
RateLimitMiddleware(RateLimitConfigs.USER_MFA_TOTP_DISABLE),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', DisableTotpRequest),
OpenAPI({
operationId: 'disable_totp_mfa',
summary: 'Disable TOTP multi-factor authentication',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Disable TOTP multi-factor authentication on the current account. Requires sudo mode verification for security.',
}),
async (ctx) => {
const body = ctx.req.valid('json');
const user = ctx.get('user');
const sudoResult = await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
await ctx.get('userAuthRequestService').disableTotp({user, data: body, sudoContext: sudoResult});
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/mfa/backup-codes',
RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', MfaBackupCodesRequest),
OpenAPI({
operationId: 'get_backup_codes_mfa',
summary: 'Get backup codes for multi-factor authentication',
responseSchema: MfaBackupCodesResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Generate and retrieve new backup codes for account recovery. Requires sudo mode verification. Old codes are invalidated.',
}),
async (ctx) => {
const body = ctx.req.valid('json');
const user = ctx.get('user');
const sudoResult = await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
return ctx.json(
await ctx.get('userAuthRequestService').getBackupCodes({user, data: body, sudoContext: sudoResult}),
);
},
);
app.post(
'/users/@me/phone/send-verification',
RateLimitMiddleware(RateLimitConfigs.PHONE_SEND_VERIFICATION),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator('json', PhoneSendVerificationRequest),
OpenAPI({
operationId: 'send_phone_verification_code',
summary: 'Send phone verification code',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Request a verification code to be sent via SMS to the provided phone number. Requires authentication.',
}),
async (ctx) => {
await ctx.get('userAuthRequestService').sendPhoneVerificationCode({
user: ctx.get('user'),
data: ctx.req.valid('json'),
});
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/phone/verify',
RateLimitMiddleware(RateLimitConfigs.PHONE_VERIFY_CODE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator('json', PhoneVerifyRequest),
OpenAPI({
operationId: 'verify_phone_code',
summary: 'Verify phone code',
responseSchema: PhoneVerifyResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description: 'Verify a phone number by confirming the SMS verification code. Returns phone verification status.',
}),
async (ctx) => {
return ctx.json(
await ctx.get('userAuthRequestService').verifyPhoneCode({user: ctx.get('user'), data: ctx.req.valid('json')}),
);
},
);
app.post(
'/users/@me/phone',
RateLimitMiddleware(RateLimitConfigs.PHONE_ADD),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', PhoneAddRequest),
OpenAPI({
operationId: 'add_phone_to_account',
summary: 'Add phone number to account',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Add or update the phone number associated with the current account. Requires sudo mode verification. Phone must be verified before use.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
const {phone_token: _phoneToken, ...sudoBody} = body;
await requireSudoMode(ctx, user, sudoBody, ctx.get('authService'), ctx.get('authMfaService'));
await ctx.get('userAuthRequestService').addPhoneToAccount({
user,
data: body,
});
return ctx.body(null, 204);
},
);
app.delete(
'/users/@me/phone',
RateLimitMiddleware(RateLimitConfigs.PHONE_REMOVE),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
OpenAPI({
operationId: 'remove_phone_from_account',
summary: 'Remove phone number from account',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Remove the phone number from the current account. Requires sudo mode verification. SMS MFA will be disabled if enabled.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
await ctx.get('userAuthRequestService').removePhoneFromAccount(user);
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/mfa/sms/enable',
RateLimitMiddleware(RateLimitConfigs.MFA_SMS_ENABLE),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
OpenAPI({
operationId: 'enable_sms_mfa',
summary: 'Enable SMS multi-factor authentication',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Enable SMS-based multi-factor authentication on the current account. Requires sudo mode verification and a verified phone number.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'), {
issueSudoToken: false,
});
await ctx.get('userAuthRequestService').enableSmsMfa(user);
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/mfa/sms/disable',
RateLimitMiddleware(RateLimitConfigs.MFA_SMS_DISABLE),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
OpenAPI({
operationId: 'disable_sms_mfa',
summary: 'Disable SMS multi-factor authentication',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Disable SMS-based multi-factor authentication on the current account. Requires sudo mode verification for security.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
await ctx.get('userAuthRequestService').disableSmsMfa(user);
return ctx.body(null, 204);
},
);
app.delete(
'/users/@me/authorized-ips',
RateLimitMiddleware(RateLimitConfigs.USER_AUTHORIZED_IPS_FORGET),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
OpenAPI({
operationId: 'forget_authorized_ips',
summary: 'Forget authorized IPs for current user',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Clears all authorized IP addresses for the current user. After calling this endpoint, the user will be required to re-authorize any new IP addresses they log in from. Requires sudo mode verification.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
await ctx.get('userAuthRequestService').forgetAuthorizedIps(user);
return ctx.body(null, 204);
},
);
app.get(
'/users/@me/mfa/webauthn/credentials',
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_LIST),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'list_webauthn_credentials',
summary: 'List WebAuthn credentials',
responseSchema: WebAuthnCredentialListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Retrieve all registered WebAuthn credentials (security keys, biometric devices) for the current user. Requires authentication.',
}),
async (ctx) => {
return ctx.json(await ctx.get('userAuthRequestService').listWebAuthnCredentials(ctx.get('user')));
},
);
app.post(
'/users/@me/mfa/webauthn/credentials/registration-options',
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_REGISTRATION_OPTIONS),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
OpenAPI({
operationId: 'get_webauthn_registration_options',
summary: 'Get WebAuthn registration options',
responseSchema: WebAuthnChallengeResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Generate challenge and options to register a new WebAuthn credential. Requires sudo mode verification.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'), {
issueSudoToken: false,
});
return ctx.json(await ctx.get('userAuthRequestService').generateWebAuthnRegistrationOptions(user));
},
);
app.post(
'/users/@me/mfa/webauthn/credentials',
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_REGISTER),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', WebAuthnRegisterRequest),
OpenAPI({
operationId: 'register_webauthn_credential',
summary: 'Register WebAuthn credential',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Complete registration of a new WebAuthn credential (security key or biometric device). Requires sudo mode verification.',
}),
async (ctx) => {
const user = ctx.get('user');
const {response, challenge, name, ...sudoBody} = ctx.req.valid('json');
await requireSudoMode(ctx, user, sudoBody, ctx.get('authService'), ctx.get('authMfaService'), {
issueSudoToken: false,
});
await ctx.get('userAuthRequestService').registerWebAuthnCredential({
user,
data: {response, challenge, name},
});
return ctx.body(null, 204);
},
);
app.patch(
'/users/@me/mfa/webauthn/credentials/:credential_id',
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_UPDATE),
LoginRequired,
DefaultUserOnly,
Validator('param', CredentialIdParam),
Validator('json', WebAuthnCredentialUpdateRequest),
SudoModeMiddleware,
OpenAPI({
operationId: 'update_webauthn_credential',
summary: 'Update WebAuthn credential',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description: 'Update the name or settings of a registered WebAuthn credential. Requires sudo mode verification.',
}),
async (ctx) => {
const user = ctx.get('user');
const {credential_id} = ctx.req.valid('param');
const {name, ...sudoBody} = ctx.req.valid('json');
await requireSudoMode(ctx, user, sudoBody, ctx.get('authService'), ctx.get('authMfaService'));
await ctx.get('userAuthRequestService').renameWebAuthnCredential({
user,
credentialId: credential_id,
data: {name},
});
return ctx.body(null, 204);
},
);
app.delete(
'/users/@me/mfa/webauthn/credentials/:credential_id',
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_DELETE),
LoginRequired,
DefaultUserOnly,
Validator('param', CredentialIdParam),
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
OpenAPI({
operationId: 'delete_webauthn_credential',
summary: 'Delete WebAuthn credential',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Remove a registered WebAuthn credential from the current account. Requires sudo mode verification for security.',
}),
async (ctx) => {
const user = ctx.get('user');
const {credential_id} = ctx.req.valid('param');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
await ctx.get('userAuthRequestService').deleteWebAuthnCredential({user, credentialId: credential_id});
return ctx.body(null, 204);
},
);
app.get(
'/users/@me/sudo/mfa-methods',
RateLimitMiddleware(RateLimitConfigs.SUDO_MFA_METHODS),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'list_sudo_mfa_methods',
summary: 'List sudo multi-factor authentication methods',
responseSchema: SudoMfaMethodsResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Retrieve all available MFA methods for sudo mode verification (TOTP, SMS, WebAuthn). Requires authentication.',
}),
async (ctx) => {
return ctx.json(await ctx.get('userAuthRequestService').listSudoMfaMethods(ctx.get('user')));
},
);
app.post(
'/users/@me/sudo/mfa/sms/send',
RateLimitMiddleware(RateLimitConfigs.SUDO_SMS_SEND),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'send_sudo_sms_code',
summary: 'Send sudo SMS code',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Request an SMS code to be sent for sudo mode verification. Used before entering sensitive account settings.',
}),
async (ctx) => {
await ctx.get('userAuthRequestService').sendSudoSmsCode(ctx.get('user'));
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/sudo/webauthn/authentication-options',
RateLimitMiddleware(RateLimitConfigs.SUDO_WEBAUTHN_OPTIONS),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'get_sudo_webauthn_authentication_options',
summary: 'Get sudo WebAuthn authentication options',
responseSchema: WebAuthnChallengeResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Generate WebAuthn challenge for sudo mode verification using a registered security key or biometric device.',
}),
async (ctx) => {
return ctx.json(await ctx.get('userAuthRequestService').getSudoWebAuthnOptions(ctx.get('user')));
},
);
}

View File

@@ -0,0 +1,131 @@
/*
* 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 {createChannelID} from '@fluxer/api/src/BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
import {Validator} from '@fluxer/api/src/Validator';
import {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import {z} from 'zod';
export function UserChannelController(app: HonoApp) {
app.get(
'/users/@me/channels',
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'list_private_channels',
summary: 'List private channels',
responseSchema: z.array(ChannelResponse),
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Retrieves all private channels (direct messages) accessible to the current user. Returns list of channel objects with metadata including recipient information.',
}),
async (ctx) => {
const response = await ctx.get('userChannelRequestService').listPrivateChannels({
userId: ctx.get('user').id,
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
app.post(
'/users/@me/channels',
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
LoginRequired,
Validator('json', CreatePrivateChannelRequest),
OpenAPI({
operationId: 'create_private_channel',
summary: 'Create private channel',
responseSchema: ChannelResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Creates a new private channel (direct message) between the current user and one or more recipients. Returns the newly created channel object.',
}),
async (ctx) => {
const response = await ctx.get('userChannelRequestService').createPrivateChannel({
userId: ctx.get('user').id,
data: ctx.req.valid('json'),
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
app.put(
'/users/@me/channels/:channel_id/pin',
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
LoginRequired,
DefaultUserOnly,
Validator('param', ChannelIdParam),
OpenAPI({
operationId: 'pin_direct_message_channel',
summary: 'Pin direct message channel',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Pins a private message channel for the current user. Pinned channels appear at the top of the channel list for easy access.',
}),
async (ctx) => {
await ctx.get('userChannelRequestService').pinChannel({
userId: ctx.get('user').id,
channelId: createChannelID(ctx.req.valid('param').channel_id),
});
return ctx.body(null, 204);
},
);
app.delete(
'/users/@me/channels/:channel_id/pin',
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
LoginRequired,
DefaultUserOnly,
Validator('param', ChannelIdParam),
OpenAPI({
operationId: 'unpin_direct_message_channel',
summary: 'Unpin direct message channel',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Unpins a private message channel for the current user. The channel will return to its normal position in the channel list based on activity.',
}),
async (ctx) => {
await ctx.get('userChannelRequestService').unpinChannel({
userId: ctx.get('user').id,
channelId: createChannelID(ctx.req.valid('param').channel_id),
});
return ctx.body(null, 204);
},
);
}

View File

@@ -0,0 +1,272 @@
/*
* 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 {createChannelID, createMessageID} from '@fluxer/api/src/BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
import {Validator} from '@fluxer/api/src/Validator';
import {HarvestIdParam, MessageIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {MessageListResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {
HarvestCreationResponseSchema,
HarvestDownloadUrlResponse,
HarvestStatusResponseSchema,
HarvestStatusResponseSchemaNullable,
} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
import {
SaveMessageRequest,
UserMentionsQueryRequest,
UserSavedMessagesQueryRequest,
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import {SavedMessageEntryListResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
export function UserContentController(app: HonoApp) {
app.get(
'/users/@me/mentions',
RateLimitMiddleware(RateLimitConfigs.USER_MENTIONS_READ),
LoginRequired,
DefaultUserOnly,
Validator('query', UserMentionsQueryRequest),
OpenAPI({
operationId: 'list_mentions_for_current_user',
summary: 'List mentions for current user',
responseSchema: MessageListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Retrieves messages where the current user was mentioned. Supports filtering by role mentions, everyone mentions, and specific guilds. Returns paginated list of messages.',
}),
async (ctx) => {
const {limit, roles, everyone, guilds, before} = ctx.req.valid('query');
const response = await ctx.get('userContentRequestService').listMentions({
userId: ctx.get('user').id,
limit,
everyone,
roles,
guilds,
before: before ? createMessageID(before) : undefined,
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
app.delete(
'/users/@me/mentions/:message_id',
RateLimitMiddleware(RateLimitConfigs.USER_MENTIONS_DELETE),
LoginRequired,
DefaultUserOnly,
Validator('param', MessageIdParam),
OpenAPI({
operationId: 'delete_mention',
summary: 'Delete mention',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
"Removes a mention from the current user's mention history. Does not delete the original message, only removes it from the user's personal mention list.",
}),
async (ctx) => {
await ctx.get('userContentRequestService').deleteMention({
userId: ctx.get('user').id,
messageId: createMessageID(ctx.req.valid('param').message_id),
});
return ctx.body(null, 204);
},
);
app.get(
'/users/@me/saved-messages',
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
LoginRequired,
DefaultUserOnly,
Validator('query', UserSavedMessagesQueryRequest),
OpenAPI({
operationId: 'list_saved_messages',
summary: 'List saved messages',
responseSchema: SavedMessageEntryListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Retrieves all messages saved by the current user. Messages are saved privately for easy reference. Returns paginated list of saved messages with metadata.',
}),
async (ctx) => {
const response = await ctx.get('userContentRequestService').listSavedMessages({
userId: ctx.get('user').id,
limit: ctx.req.valid('query').limit,
requestCache: ctx.get('requestCache'),
});
return ctx.json(response, 200);
},
);
app.post(
'/users/@me/saved-messages',
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
LoginRequired,
DefaultUserOnly,
Validator('json', SaveMessageRequest),
OpenAPI({
operationId: 'save_message',
summary: 'Save message',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Saves a message for the current user. Saved messages can be accessed later from the saved messages list. Messages are saved privately.',
}),
async (ctx) => {
const {channel_id, message_id} = ctx.req.valid('json');
await ctx.get('userContentRequestService').saveMessage({
userId: ctx.get('user').id,
channelId: createChannelID(channel_id),
messageId: createMessageID(message_id),
requestCache: ctx.get('requestCache'),
});
return ctx.body(null, 204);
},
);
app.delete(
'/users/@me/saved-messages/:message_id',
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
LoginRequired,
DefaultUserOnly,
Validator('param', MessageIdParam),
OpenAPI({
operationId: 'unsave_message',
summary: 'Unsave message',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
"Removes a message from the current user's saved messages. Does not delete the original message, only removes it from the user's saved collection.",
}),
async (ctx) => {
await ctx.get('userContentRequestService').unsaveMessage({
userId: ctx.get('user').id,
messageId: createMessageID(ctx.req.valid('param').message_id),
});
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/harvest',
RateLimitMiddleware(RateLimitConfigs.USER_DATA_HARVEST),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'request_data_harvest',
summary: 'Request data harvest',
responseSchema: HarvestCreationResponseSchema,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Requests a data harvest of all user data and content. Initiates an asynchronous process to compile and prepare all data for download in a portable format. Returns harvest ID and status.',
}),
async (ctx) => {
const result = await ctx.get('userContentRequestService').requestHarvest({userId: ctx.get('user').id});
return ctx.json(result, 200);
},
);
app.get(
'/users/@me/harvest/latest',
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_LATEST),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'get_latest_data_harvest',
summary: 'Get latest data harvest',
responseSchema: HarvestStatusResponseSchemaNullable,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Retrieves the status of the most recent data harvest request. Returns null if no harvest has been requested yet. Shows progress and estimated completion time.',
}),
async (ctx) => {
const harvest = await ctx.get('userContentRequestService').getLatestHarvest({userId: ctx.get('user').id});
return ctx.json(harvest, 200);
},
);
app.get(
'/users/@me/harvest/:harvestId',
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_STATUS),
LoginRequired,
DefaultUserOnly,
Validator('param', HarvestIdParam),
OpenAPI({
operationId: 'get_data_harvest_status',
summary: 'Get data harvest status',
responseSchema: HarvestStatusResponseSchema,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Retrieves detailed status information for a specific data harvest. Shows progress, completion status, and other metadata about the harvest request.',
}),
async (ctx) => {
const {harvestId} = ctx.req.valid('param');
const harvest = await ctx.get('userContentRequestService').getHarvestStatus({
userId: ctx.get('user').id,
harvestId,
});
return ctx.json(harvest, 200);
},
);
app.get(
'/users/@me/harvest/:harvestId/download',
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_DOWNLOAD),
LoginRequired,
DefaultUserOnly,
Validator('param', HarvestIdParam),
OpenAPI({
operationId: 'get_data_harvest_download_url',
summary: 'Get data harvest download URL',
responseSchema: HarvestDownloadUrlResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Retrieves the download URL for a completed data harvest. The URL is temporary and expires after a set time. Can only be accessed for completed harvests.',
}),
async (ctx) => {
const {harvestId} = ctx.req.valid('param');
const result = await ctx.get('userContentRequestService').getHarvestDownloadUrl({
userId: ctx.get('user').id,
harvestId,
storageService: ctx.get('storageService'),
});
return ctx.json(result, 200);
},
);
}

View File

@@ -0,0 +1,35 @@
/*
* 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 {HonoApp} from '@fluxer/api/src/types/HonoEnv';
import {UserAccountController} from '@fluxer/api/src/user/controllers/UserAccountController';
import {UserAuthController} from '@fluxer/api/src/user/controllers/UserAuthController';
import {UserChannelController} from '@fluxer/api/src/user/controllers/UserChannelController';
import {UserContentController} from '@fluxer/api/src/user/controllers/UserContentController';
import {UserRelationshipController} from '@fluxer/api/src/user/controllers/UserRelationshipController';
import {UserScheduledMessageController} from '@fluxer/api/src/user/controllers/UserScheduledMessageController';
export function UserController(app: HonoApp) {
UserAccountController(app);
UserAuthController(app);
UserRelationshipController(app);
UserChannelController(app);
UserContentController(app);
UserScheduledMessageController(app);
}

View File

@@ -0,0 +1,193 @@
/*
* 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 {createUserID} from '@fluxer/api/src/BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
import {Validator} from '@fluxer/api/src/Validator';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
FriendRequestByTagRequest,
RelationshipNicknameUpdateRequest,
RelationshipTypePutRequest,
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import {RelationshipResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {z} from 'zod';
export function UserRelationshipController(app: HonoApp) {
app.get(
'/users/@me/relationships',
RateLimitMiddleware(RateLimitConfigs.USER_RELATIONSHIPS_LIST),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'list_user_relationships',
summary: 'List user relationships',
responseSchema: z.array(RelationshipResponse),
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Retrieves all relationships for the current user, including friends, friend requests (incoming and outgoing), and blocked users. Returns list of relationship objects with type and metadata.',
}),
async (ctx) => {
const response = await ctx.get('userRelationshipRequestService').listRelationships({
userId: ctx.get('user').id,
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
app.post(
'/users/@me/relationships',
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_SEND),
LoginRequired,
DefaultUserOnly,
Validator('json', FriendRequestByTagRequest),
OpenAPI({
operationId: 'send_friend_request_by_tag',
summary: 'Send friend request by tag',
responseSchema: RelationshipResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Sends a friend request to a user identified by username tag (username#discriminator). Returns the new relationship object. Can fail if user not found or request already sent.',
}),
async (ctx) => {
const response = await ctx.get('userRelationshipRequestService').sendFriendRequestByTag({
userId: ctx.get('user').id,
data: ctx.req.valid('json'),
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
app.post(
'/users/@me/relationships/:user_id',
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_SEND),
LoginRequired,
DefaultUserOnly,
Validator('param', UserIdParam),
OpenAPI({
operationId: 'send_friend_request',
summary: 'Send friend request',
responseSchema: RelationshipResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Sends a friend request to a user identified by user ID. Returns the new relationship object. Can fail if user not found or request already sent.',
}),
async (ctx) => {
const response = await ctx.get('userRelationshipRequestService').sendFriendRequest({
userId: ctx.get('user').id,
targetId: createUserID(ctx.req.valid('param').user_id),
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
app.put(
'/users/@me/relationships/:user_id',
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_ACCEPT),
LoginRequired,
DefaultUserOnly,
Validator('param', UserIdParam),
Validator('json', RelationshipTypePutRequest),
OpenAPI({
operationId: 'accept_or_update_friend_request',
summary: 'Accept or update friend request',
responseSchema: RelationshipResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Accepts a pending incoming friend request from a user or updates the relationship type. Can also be used to change friend relationship to blocked status. Returns updated relationship object.',
}),
async (ctx) => {
const response = await ctx.get('userRelationshipRequestService').updateRelationshipType({
userId: ctx.get('user').id,
targetId: createUserID(ctx.req.valid('param').user_id),
data: ctx.req.valid('json'),
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
app.delete(
'/users/@me/relationships/:user_id',
RateLimitMiddleware(RateLimitConfigs.USER_RELATIONSHIP_DELETE),
LoginRequired,
DefaultUserOnly,
Validator('param', UserIdParam),
OpenAPI({
operationId: 'remove_relationship',
summary: 'Remove relationship',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Removes a relationship with another user by ID. Removes friends, cancels friend requests (incoming or outgoing), or unblocks a blocked user depending on current relationship type.',
}),
async (ctx) => {
await ctx.get('userRelationshipRequestService').removeRelationship({
userId: ctx.get('user').id,
targetId: createUserID(ctx.req.valid('param').user_id),
});
return ctx.body(null, 204);
},
);
app.patch(
'/users/@me/relationships/:user_id',
RateLimitMiddleware(RateLimitConfigs.USER_RELATIONSHIP_UPDATE),
LoginRequired,
DefaultUserOnly,
Validator('param', UserIdParam),
Validator('json', RelationshipNicknameUpdateRequest),
OpenAPI({
operationId: 'update_relationship_nickname',
summary: 'Update relationship nickname',
responseSchema: RelationshipResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
"Updates the nickname associated with a relationship (friend or blocked user). Nicknames are personal labels that override the user's display name in the current user's view. Returns updated relationship object.",
}),
async (ctx) => {
const response = await ctx.get('userRelationshipRequestService').updateNickname({
userId: ctx.get('user').id,
targetId: createUserID(ctx.req.valid('param').user_id),
data: ctx.req.valid('json'),
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
}

View File

@@ -0,0 +1,163 @@
/*
* 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} from '@fluxer/api/src/BrandedTypes';
import {parseScheduledMessageInput} from '@fluxer/api/src/channel/controllers/ScheduledMessageParsing';
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@fluxer/api/src/types/HonoEnv';
import {Validator} from '@fluxer/api/src/Validator';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {ScheduledMessageIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ScheduledMessageResponseSchema} from '@fluxer/schema/src/domains/message/ScheduledMessageSchemas';
import type {Context} from 'hono';
import {z} from 'zod';
export function UserScheduledMessageController(app: HonoApp) {
app.get(
'/users/@me/scheduled-messages',
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'list_scheduled_messages',
summary: 'List scheduled messages',
responseSchema: z.array(ScheduledMessageResponseSchema),
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Retrieves all scheduled messages for the current user. Returns list of messages that are scheduled to be sent at a future date and time.',
}),
async (ctx: Context<HonoEnv>) => {
const userId = ctx.get('user').id;
const scheduledMessageService = ctx.get('scheduledMessageService');
const scheduledMessages = await scheduledMessageService.listScheduledMessages(userId);
return ctx.json(
scheduledMessages.map((message) => message.toResponse()),
200,
);
},
);
app.get(
'/users/@me/scheduled-messages/:scheduled_message_id',
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
LoginRequired,
DefaultUserOnly,
Validator('param', ScheduledMessageIdParam),
OpenAPI({
operationId: 'get_scheduled_message',
summary: 'Get scheduled message',
responseSchema: ScheduledMessageResponseSchema,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Retrieves details of a specific scheduled message by ID. Returns the message content, scheduled send time, and status.',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const scheduledMessageId = createMessageID(ctx.req.valid('param').scheduled_message_id);
const scheduledMessageService = ctx.get('scheduledMessageService');
const scheduledMessage = await scheduledMessageService.getScheduledMessage(userId, scheduledMessageId);
if (!scheduledMessage) {
throw new UnknownMessageError();
}
return ctx.json(scheduledMessage.toResponse(), 200);
},
);
app.delete(
'/users/@me/scheduled-messages/:scheduled_message_id',
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
LoginRequired,
DefaultUserOnly,
Validator('param', ScheduledMessageIdParam),
OpenAPI({
operationId: 'cancel_scheduled_message',
summary: 'Cancel scheduled message',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Cancels and deletes a scheduled message before it is sent. The message will not be delivered if cancelled.',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const scheduledMessageId = createMessageID(ctx.req.valid('param').scheduled_message_id);
const scheduledMessageService = ctx.get('scheduledMessageService');
await scheduledMessageService.cancelScheduledMessage(userId, scheduledMessageId);
return ctx.body(null, 204);
},
);
app.patch(
'/users/@me/scheduled-messages/:scheduled_message_id',
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
LoginRequired,
DefaultUserOnly,
Validator('param', ScheduledMessageIdParam),
OpenAPI({
operationId: 'update_scheduled_message',
summary: 'Update scheduled message',
responseSchema: ScheduledMessageResponseSchema,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Updates an existing scheduled message before it is sent. Can modify message content, scheduled time, and timezone. Returns updated scheduled message details.',
}),
async (ctx) => {
const user = ctx.get('user');
const scheduledMessageService = ctx.get('scheduledMessageService');
const scheduledMessageId = createMessageID(ctx.req.valid('param').scheduled_message_id);
const existingMessage = await scheduledMessageService.getScheduledMessage(user.id, scheduledMessageId);
if (!existingMessage) {
throw new UnknownMessageError();
}
const channelId = existingMessage.channelId;
const {message, scheduledLocalAt, timezone} = await parseScheduledMessageInput({
ctx,
user,
channelId,
});
const scheduledMessage = await scheduledMessageService.updateScheduledMessage({
user,
channelId,
data: message,
scheduledLocalAt,
timezone,
scheduledMessageId,
existing: existingMessage,
});
return ctx.json(scheduledMessage.toResponse(), 200);
},
);
}

View File

@@ -0,0 +1,169 @@
/*
* 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 {BatchBuilder, Db, executeConditional, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {GiftCodeRow} from '@fluxer/api/src/database/types/PaymentTypes';
import {GiftCode} from '@fluxer/api/src/models/GiftCode';
import {GiftCodes, GiftCodesByCreator, GiftCodesByPaymentIntent, GiftCodesByRedeemer} from '@fluxer/api/src/Tables';
const FETCH_GIFT_CODES_BY_CREATOR_QUERY = GiftCodesByCreator.selectCql({
where: GiftCodesByCreator.where.eq('created_by_user_id'),
});
const FETCH_GIFT_CODE_BY_PAYMENT_INTENT_QUERY = GiftCodesByPaymentIntent.selectCql({
columns: ['code'],
where: GiftCodesByPaymentIntent.where.eq('stripe_payment_intent_id'),
limit: 1,
});
const FETCH_GIFT_CODE_QUERY = GiftCodes.selectCql({
where: GiftCodes.where.eq('code'),
limit: 1,
});
export class GiftCodeRepository {
async createGiftCode(data: GiftCodeRow): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(GiftCodes.upsertAll(data));
batch.addPrepared(
GiftCodesByCreator.upsertAll({
created_by_user_id: data.created_by_user_id,
code: data.code,
}),
);
if (data.stripe_payment_intent_id) {
batch.addPrepared(
GiftCodesByPaymentIntent.upsertAll({
stripe_payment_intent_id: data.stripe_payment_intent_id,
code: data.code,
}),
);
}
await batch.execute();
}
async findGiftCode(code: string): Promise<GiftCode | null> {
const row = await fetchOne<GiftCodeRow>(FETCH_GIFT_CODE_QUERY, {code});
if (!row) {
return null;
}
return new GiftCode(row);
}
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
const row = await fetchOne<{code: string}>(FETCH_GIFT_CODE_BY_PAYMENT_INTENT_QUERY, {
stripe_payment_intent_id: paymentIntentId,
});
if (!row) {
return null;
}
return this.findGiftCode(row.code);
}
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
const codes = await fetchMany<{code: string}>(FETCH_GIFT_CODES_BY_CREATOR_QUERY, {
created_by_user_id: userId,
});
if (codes.length === 0) {
return [];
}
const gifts: Array<GiftCode> = [];
for (const {code} of codes) {
const gift = await this.findGiftCode(code);
if (gift) {
gifts.push(gift);
}
}
return gifts;
}
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
const redeemedAt = new Date();
const q = GiftCodes.patchByPkIf(
{code},
{
redeemed_by_user_id: Db.set(userId),
redeemed_at: Db.set(redeemedAt),
},
{col: 'redeemed_by_user_id', expectedParam: 'expected_redeemer', expectedValue: null},
);
const result = await executeConditional(q);
if (result.applied) {
await upsertOne(
GiftCodesByRedeemer.upsertAll({
redeemed_by_user_id: userId,
code,
}),
);
}
return result;
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
const batch = new BatchBuilder();
const patch: Record<string, ReturnType<typeof Db.set>> = {};
if (data['redeemed_at'] !== undefined) {
patch['redeemed_at'] = Db.set(data['redeemed_at']);
}
if (data['redeemed_by_user_id'] !== undefined) {
patch['redeemed_by_user_id'] = Db.set(data['redeemed_by_user_id']);
}
if (Object.keys(patch).length > 0) {
batch.addPrepared(GiftCodes.patchByPk({code}, patch));
}
if (data.redeemed_by_user_id) {
batch.addPrepared(
GiftCodesByRedeemer.upsertAll({
redeemed_by_user_id: data.redeemed_by_user_id,
code,
}),
);
}
await batch.execute();
}
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
await upsertOne(
GiftCodes.patchByPk(
{code},
{
checkout_session_id: Db.set(checkoutSessionId),
},
),
);
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import type {User} from '@fluxer/api/src/models/User';
export interface IUserAccountRepository {
create(data: UserRow): Promise<User>;
upsert(data: UserRow, oldData?: UserRow | null): Promise<User>;
patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User>;
findUnique(userId: UserID): Promise<User | null>;
findUniqueAssert(userId: UserID): Promise<User>;
findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null>;
findDiscriminatorsByUsername(username: string): Promise<Set<number>>;
findByEmail(email: string): Promise<User | null>;
findByPhone(phone: string): Promise<User | null>;
findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null>;
findByStripeCustomerId(stripeCustomerId: string): Promise<User | null>;
listUsers(userIds: Array<UserID>): Promise<Array<User>>;
listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>>;
getUserGuildIds(userId: UserID): Promise<Array<GuildID>>;
addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void>;
removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void>;
findUsersPendingDeletion(now: Date): Promise<Array<User>>;
findUsersPendingDeletionByDate(deletionDate: string): Promise<Array<{user_id: bigint; deletion_reason_code: number}>>;
isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean>;
scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void>;
deleteUserSecondaryIndices(userId: UserID): Promise<void>;
removeFromAllGuilds(userId: UserID): Promise<void>;
updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void>;
updateSubscriptionStatus(
userId: UserID,
updates: {premiumWillCancel: boolean; computedPremiumUntil: Date | null},
): Promise<{finalVersion: number | null}>;
}

View File

@@ -0,0 +1,88 @@
/*
* 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 {PhoneVerificationToken, UserID} from '@fluxer/api/src/BrandedTypes';
import type {
AuthSessionRow,
EmailRevertTokenRow,
EmailVerificationTokenRow,
PasswordResetTokenRow,
PhoneTokenRow,
} from '@fluxer/api/src/database/types/AuthTypes';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {EmailRevertToken} from '@fluxer/api/src/models/EmailRevertToken';
import type {EmailVerificationToken} from '@fluxer/api/src/models/EmailVerificationToken';
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
import type {PasswordResetToken} from '@fluxer/api/src/models/PasswordResetToken';
import type {WebAuthnCredential} from '@fluxer/api/src/models/WebAuthnCredential';
export interface IUserAuthRepository {
listAuthSessions(userId: UserID): Promise<Array<AuthSession>>;
getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null>;
createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession>;
updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void>;
deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void>;
revokeAuthSession(sessionIdHash: Buffer): Promise<void>;
deleteAllAuthSessions(userId: UserID): Promise<void>;
listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>>;
createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>>;
clearMfaBackupCodes(userId: UserID): Promise<void>;
consumeMfaBackupCode(userId: UserID, code: string): Promise<void>;
deleteAllMfaBackupCodes(userId: UserID): Promise<void>;
getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null>;
createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken>;
deleteEmailVerificationToken(token: string): Promise<void>;
getPasswordResetToken(token: string): Promise<PasswordResetToken | null>;
createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken>;
deletePasswordResetToken(token: string): Promise<void>;
getEmailRevertToken(token: string): Promise<EmailRevertToken | null>;
createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken>;
deleteEmailRevertToken(token: string): Promise<void>;
createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void>;
getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null>;
deletePhoneToken(token: PhoneVerificationToken): Promise<void>;
updateUserActivity(userId: UserID, clientIp: string): Promise<void>;
checkIpAuthorized(userId: UserID, ip: string): Promise<boolean>;
createAuthorizedIp(userId: UserID, ip: string): Promise<void>;
createIpAuthorizationToken(userId: UserID, token: string, email: string): Promise<void>;
authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null>;
deleteAllAuthorizedIps(userId: UserID): Promise<void>;
listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>>;
getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null>;
createWebAuthnCredential(
userId: UserID,
credentialId: string,
publicKey: Buffer,
counter: bigint,
transports: Set<string> | null,
name: string,
): Promise<void>;
updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void>;
updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void>;
updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void>;
deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void>;
getUserIdByCredentialId(credentialId: string): Promise<UserID | null>;
deleteAllWebAuthnCredentials(userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,69 @@
/*
* 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 type {Channel} from '@fluxer/api/src/models/Channel';
export interface PrivateChannelSummary {
channelId: ChannelID;
isGroupDm: boolean;
channelType: number | null;
lastMessageId: MessageID | null;
open: boolean;
}
export interface ListHistoricalDmChannelOptions {
limit: number;
beforeChannelId?: ChannelID;
afterChannelId?: ChannelID;
}
export interface HistoricalDmChannelSummary {
channelId: ChannelID;
channelType: number | null;
recipientIds: Array<UserID>;
lastMessageId: MessageID | null;
open: boolean;
}
export interface IUserChannelRepository {
listPrivateChannels(userId: UserID): Promise<Array<Channel>>;
deleteAllPrivateChannels(userId: UserID): Promise<void>;
listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>>;
listHistoricalDmChannelIds(userId: UserID): Promise<Array<ChannelID>>;
listHistoricalDmChannelsPaginated(
userId: UserID,
options: ListHistoricalDmChannelOptions,
): Promise<Array<HistoricalDmChannelSummary>>;
recordHistoricalDmChannel(userId: UserID, channelId: ChannelID, isGroupDm: boolean): Promise<void>;
findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null>;
createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel>;
isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean>;
openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void>;
closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void>;
getPinnedDms(userId: UserID): Promise<Array<ChannelID>>;
getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>>;
addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>>;
removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>>;
deletePinnedDmsByUserId(userId: UserID): Promise<void>;
deleteAllReadStates(userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,84 @@
/*
* 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 type {ExactRow} from '@fluxer/api/src/database/types/DatabaseRowTypes';
import type {GiftCodeRow, PaymentBySubscriptionRow, PaymentRow} from '@fluxer/api/src/database/types/PaymentTypes';
import type {PushSubscriptionRow, RecentMentionRow} from '@fluxer/api/src/database/types/UserTypes';
import type {GiftCode} from '@fluxer/api/src/models/GiftCode';
import type {Payment} from '@fluxer/api/src/models/Payment';
import type {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
import type {RecentMention} from '@fluxer/api/src/models/RecentMention';
import type {SavedMessage} from '@fluxer/api/src/models/SavedMessage';
import type {VisionarySlot} from '@fluxer/api/src/models/VisionarySlot';
export interface IUserContentRepository {
getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null>;
listRecentMentions(
userId: UserID,
includeEveryone: boolean,
includeRole: boolean,
includeGuilds: boolean,
limit: number,
before?: MessageID,
): Promise<Array<RecentMention>>;
createRecentMention(mention: ExactRow<RecentMentionRow>): Promise<RecentMention>;
createRecentMentions(mentions: Array<ExactRow<RecentMentionRow>>): Promise<void>;
deleteRecentMention(mention: RecentMention): Promise<void>;
deleteAllRecentMentions(userId: UserID): Promise<void>;
listSavedMessages(userId: UserID, limit?: number, before?: MessageID): Promise<Array<SavedMessage>>;
createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage>;
deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void>;
deleteAllSavedMessages(userId: UserID): Promise<void>;
createGiftCode(data: ExactRow<GiftCodeRow>): Promise<void>;
findGiftCode(code: string): Promise<GiftCode | null>;
findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null>;
findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>>;
redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}>;
updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void>;
linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void>;
listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>>;
createPushSubscription(data: ExactRow<PushSubscriptionRow>): Promise<PushSubscription>;
deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void>;
getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>>;
deleteAllPushSubscriptions(userId: UserID): Promise<void>;
createPayment(data: {
checkout_session_id: string;
user_id: UserID;
price_id: string;
product_type: string;
status: string;
is_gift: boolean;
created_at: Date;
}): Promise<void>;
updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}>;
getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null>;
getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null>;
getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null>;
listVisionarySlots(): Promise<Array<VisionarySlot>>;
expandVisionarySlots(byCount: number): Promise<void>;
shrinkVisionarySlots(toCount: number): Promise<void>;
reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void>;
unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,39 @@
/*
* 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 {RelationshipRow} from '@fluxer/api/src/database/types/UserTypes';
import type {Relationship} from '@fluxer/api/src/models/Relationship';
import type {UserNote} from '@fluxer/api/src/models/UserNote';
export interface IUserRelationshipRepository {
listRelationships(sourceUserId: UserID): Promise<Array<Relationship>>;
hasReachedRelationshipLimit(sourceUserId: UserID, limit: number): Promise<boolean>;
getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null>;
upsertRelationship(relationship: RelationshipRow): Promise<Relationship>;
deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void>;
deleteAllRelationships(userId: UserID): Promise<void>;
backfillRelationshipsIndex(userId: UserID, relationships: Array<Relationship>): Promise<void>;
getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null>;
getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>>;
upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote>;
clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void>;
deleteAllNotes(userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,33 @@
/*
* 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 {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';
export interface IUserRepositoryAggregate
extends IUserAccountRepository,
IUserAuthRepository,
IUserSettingsRepository,
IUserRelationshipRepository,
IUserChannelRepository,
IUserContentRepository {}

View File

@@ -0,0 +1,36 @@
/*
* 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 {ExactRow} from '@fluxer/api/src/database/types/DatabaseRowTypes';
import type {UserGuildSettingsRow, UserSettingsRow} from '@fluxer/api/src/database/types/UserTypes';
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
export interface IUserSettingsRepository {
findSettings(userId: UserID): Promise<UserSettings | null>;
upsertSettings(settings: ExactRow<UserSettingsRow>): Promise<UserSettings>;
deleteUserSettings(userId: UserID): Promise<void>;
findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null>;
findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>>;
upsertGuildSettings(settings: ExactRow<UserGuildSettingsRow>): Promise<UserGuildSettings>;
deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void>;
deleteAllUserGuildSettings(userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,204 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, Db, executeVersionedUpdate, fetchMany, fetchOne} from '@fluxer/api/src/database/Cassandra';
import type {PaymentBySubscriptionRow, PaymentRow} from '@fluxer/api/src/database/types/PaymentTypes';
import {Payment} from '@fluxer/api/src/models/Payment';
import {Payments, PaymentsByPaymentIntent, PaymentsBySubscription, PaymentsByUser} from '@fluxer/api/src/Tables';
const FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY = Payments.selectCql({
where: Payments.where.eq('checkout_session_id'),
limit: 1,
});
const FETCH_PAYMENT_BY_PAYMENT_INTENT_QUERY = PaymentsByPaymentIntent.selectCql({
columns: ['checkout_session_id'],
where: PaymentsByPaymentIntent.where.eq('payment_intent_id'),
});
const FETCH_PAYMENT_BY_SUBSCRIPTION_QUERY = PaymentsBySubscription.selectCql({
where: PaymentsBySubscription.where.eq('subscription_id'),
});
const FETCH_PAYMENTS_BY_USER_QUERY = PaymentsByUser.selectCql({
columns: ['checkout_session_id'],
where: PaymentsByUser.where.eq('user_id'),
});
const FETCH_PAYMENTS_BY_IDS_QUERY = Payments.selectCql({
where: Payments.where.in('checkout_session_id', 'checkout_session_ids'),
});
export class PaymentRepository {
async createPayment(data: {
checkout_session_id: string;
user_id: UserID;
price_id: string;
product_type: string;
status: string;
is_gift: boolean;
created_at: Date;
}): Promise<void> {
const batch = new BatchBuilder();
const paymentRow: PaymentRow = {
checkout_session_id: data.checkout_session_id,
user_id: data.user_id,
price_id: data.price_id,
product_type: data.product_type,
status: data.status,
is_gift: data.is_gift,
created_at: data.created_at,
stripe_customer_id: null,
payment_intent_id: null,
subscription_id: null,
invoice_id: null,
amount_cents: 0,
currency: '',
gift_code: null,
completed_at: null,
version: 1,
};
batch.addPrepared(Payments.upsertAll(paymentRow));
batch.addPrepared(
PaymentsByUser.upsertAll({
user_id: data.user_id,
created_at: data.created_at,
checkout_session_id: data.checkout_session_id,
}),
);
await batch.execute();
}
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
const checkoutSessionId = data.checkout_session_id;
const result = await executeVersionedUpdate(
() =>
fetchOne<PaymentRow>(FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY, {
checkout_session_id: checkoutSessionId,
}),
(current) => {
type PatchOp = ReturnType<typeof Db.set> | ReturnType<typeof Db.clear>;
const patch: Record<string, PatchOp> = {};
const addField = <K extends keyof PaymentRow>(key: K) => {
const newVal = data[key];
const oldVal = current?.[key];
if (newVal === null) {
if (current && oldVal !== null && oldVal !== undefined) {
patch[key] = Db.clear();
}
} else if (newVal !== undefined) {
patch[key] = Db.set(newVal);
}
};
addField('stripe_customer_id');
addField('payment_intent_id');
addField('subscription_id');
addField('invoice_id');
addField('amount_cents');
addField('currency');
addField('status');
addField('gift_code');
addField('completed_at');
return {
pk: {checkout_session_id: checkoutSessionId},
patch,
};
},
Payments,
);
if (result.applied) {
await this.updatePaymentIndexes(data);
}
return result;
}
private async updatePaymentIndexes(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<void> {
const batch = new BatchBuilder();
if (data.payment_intent_id) {
batch.addPrepared(
PaymentsByPaymentIntent.upsertAll({
payment_intent_id: data.payment_intent_id,
checkout_session_id: data.checkout_session_id,
}),
);
}
if (data.subscription_id) {
const payment = await this.getPaymentByCheckoutSession(data.checkout_session_id);
if (payment?.priceId && payment.productType) {
batch.addPrepared(
PaymentsBySubscription.upsertAll({
subscription_id: data.subscription_id,
checkout_session_id: data.checkout_session_id,
user_id: payment.userId,
price_id: payment.priceId,
product_type: payment.productType,
}),
);
}
}
await batch.execute();
}
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
const result = await fetchOne<PaymentRow>(FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY, {
checkout_session_id: checkoutSessionId,
});
return result ? new Payment(result) : null;
}
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
const mapping = await fetchOne<{checkout_session_id: string}>(FETCH_PAYMENT_BY_PAYMENT_INTENT_QUERY, {
payment_intent_id: paymentIntentId,
});
if (!mapping) return null;
return this.getPaymentByCheckoutSession(mapping.checkout_session_id);
}
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
const result = await fetchOne<PaymentBySubscriptionRow>(FETCH_PAYMENT_BY_SUBSCRIPTION_QUERY, {
subscription_id: subscriptionId,
});
return result ?? null;
}
async findPaymentsByUserId(userId: UserID): Promise<Array<Payment>> {
const paymentRefs = await fetchMany<{checkout_session_id: string}>(FETCH_PAYMENTS_BY_USER_QUERY, {
user_id: userId,
});
if (paymentRefs.length === 0) return [];
const rows = await fetchMany<PaymentRow>(FETCH_PAYMENTS_BY_IDS_QUERY, {
checkout_session_ids: paymentRefs.map((r) => r.checkout_session_id),
});
return rows.map((r) => new Payment(r));
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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 {deleteOneOrMany, fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {PushSubscriptionRow} from '@fluxer/api/src/database/types/UserTypes';
import {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
import {PushSubscriptions} from '@fluxer/api/src/Tables';
const FETCH_PUSH_SUBSCRIPTIONS_CQL = PushSubscriptions.selectCql({
where: PushSubscriptions.where.eq('user_id'),
});
const FETCH_BULK_PUSH_SUBSCRIPTIONS_CQL = PushSubscriptions.selectCql({
where: PushSubscriptions.where.in('user_id', 'user_ids'),
});
export class PushSubscriptionRepository {
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
const rows = await fetchMany<PushSubscriptionRow>(FETCH_PUSH_SUBSCRIPTIONS_CQL, {user_id: userId});
return rows.map((row) => new PushSubscription(row));
}
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
await upsertOne(PushSubscriptions.upsertAll(data));
return new PushSubscription(data);
}
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
await deleteOneOrMany(PushSubscriptions.deleteByPk({user_id: userId, subscription_id: subscriptionId}));
}
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
if (userIds.length === 0) return new Map();
const rows = await fetchMany<PushSubscriptionRow>(FETCH_BULK_PUSH_SUBSCRIPTIONS_CQL, {user_ids: userIds});
const map = new Map<UserID, Array<PushSubscription>>();
for (const row of rows) {
const sub = new PushSubscription(row);
const existing = map.get(row.user_id) ?? [];
existing.push(sub);
map.set(row.user_id, existing);
}
return map;
}
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
await deleteOneOrMany(
PushSubscriptions.delete({where: PushSubscriptions.where.eq('user_id')}).bind({user_id: userId}),
);
}
}

View File

@@ -0,0 +1,151 @@
/*
* 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, type MessageID, type UserID} from '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne} from '@fluxer/api/src/database/Cassandra';
import type {RecentMentionRow} from '@fluxer/api/src/database/types/UserTypes';
import {RecentMention} from '@fluxer/api/src/models/RecentMention';
import {RecentMentions, RecentMentionsByGuild} from '@fluxer/api/src/Tables';
import {generateSnowflake} from '@fluxer/snowflake/src/Snowflake';
const FETCH_RECENT_MENTION_CQL = RecentMentions.selectCql({
where: [RecentMentions.where.eq('user_id'), RecentMentions.where.eq('message_id')],
limit: 1,
});
const createFetchRecentMentionsQuery = (limit: number) =>
RecentMentions.selectCql({
where: [RecentMentions.where.eq('user_id'), RecentMentions.where.lt('message_id', 'before_message_id')],
limit,
});
export class RecentMentionRepository {
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
const mention = await fetchOne<RecentMentionRow>(FETCH_RECENT_MENTION_CQL, {
user_id: userId,
message_id: messageId,
});
return mention ? new RecentMention(mention) : null;
}
async listRecentMentions(
userId: UserID,
includeEveryone: boolean = true,
includeRole: boolean = true,
includeGuilds: boolean = true,
limit: number = 25,
before?: MessageID,
): Promise<Array<RecentMention>> {
const fetchLimit = Math.max(limit * 2, 50);
const query = createFetchRecentMentionsQuery(fetchLimit);
const params: {user_id: UserID; before_message_id: MessageID} = {
user_id: userId,
before_message_id: before || createMessageID(generateSnowflake()),
};
const allMentions = await fetchMany<RecentMentionRow>(query, params);
const filteredMentions = allMentions.filter((mention) => {
if (!includeEveryone && mention.is_everyone) return false;
if (!includeRole && mention.is_role) return false;
if (!includeGuilds && mention.guild_id != null) return false;
return true;
});
return filteredMentions.slice(0, limit).map((mention) => new RecentMention(mention));
}
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
const batch = new BatchBuilder();
batch.addPrepared(RecentMentions.upsertAll(mention));
batch.addPrepared(
RecentMentionsByGuild.insert({
user_id: mention.user_id,
guild_id: mention.guild_id,
message_id: mention.message_id,
channel_id: mention.channel_id,
is_everyone: mention.is_everyone,
is_role: mention.is_role,
}),
);
await batch.execute();
return new RecentMention(mention);
}
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
if (mentions.length === 0) {
return;
}
const batch = new BatchBuilder();
for (const mention of mentions) {
batch.addPrepared(RecentMentions.upsertAll(mention));
batch.addPrepared(
RecentMentionsByGuild.insert({
user_id: mention.user_id,
guild_id: mention.guild_id,
message_id: mention.message_id,
channel_id: mention.channel_id,
is_everyone: mention.is_everyone,
is_role: mention.is_role,
}),
);
}
await batch.execute();
}
async deleteRecentMention(mention: RecentMention): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(RecentMentions.deleteByPk({user_id: mention.userId, message_id: mention.messageId}));
batch.addPrepared(
RecentMentionsByGuild.deleteByPk({
user_id: mention.userId,
guild_id: mention.guildId,
message_id: mention.messageId,
}),
);
await batch.execute();
}
async deleteAllRecentMentions(userId: UserID): Promise<void> {
const mentions = await fetchMany<{guild_id: bigint; message_id: bigint}>(
RecentMentions.selectCql({
columns: ['guild_id', 'message_id'],
where: RecentMentions.where.eq('user_id'),
}),
{
user_id: userId,
},
);
const batch = new BatchBuilder();
batch.addPrepared(RecentMentions.delete({where: RecentMentions.where.eq('user_id')}).bind({user_id: userId}));
for (const mention of mentions) {
batch.addPrepared(
RecentMentionsByGuild.deleteByPk({
guild_id: mention.guild_id,
user_id: userId,
message_id: mention.message_id,
}),
);
}
if (batch) {
await batch.execute();
}
}
}

View File

@@ -0,0 +1,70 @@
/*
* 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, createMessageID, type MessageID, type UserID} from '@fluxer/api/src/BrandedTypes';
import {deleteOneOrMany, fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {SavedMessageRow} from '@fluxer/api/src/database/types/UserTypes';
import {SavedMessage} from '@fluxer/api/src/models/SavedMessage';
import {SavedMessages} from '@fluxer/api/src/Tables';
import {generateSnowflake} from '@fluxer/snowflake/src/Snowflake';
const createFetchSavedMessagesQuery = (limit: number) =>
SavedMessages.selectCql({
where: [SavedMessages.where.eq('user_id'), SavedMessages.where.lt('message_id', 'before_message_id')],
limit,
});
export class SavedMessageRepository {
async listSavedMessages(
userId: UserID,
limit: number = 25,
before: MessageID = createMessageID(generateSnowflake()),
): Promise<Array<SavedMessage>> {
const fetchLimit = Math.max(limit * 2, 50);
const savedMessageRows = await fetchMany<SavedMessageRow>(createFetchSavedMessagesQuery(fetchLimit), {
user_id: userId,
before_message_id: before,
});
const savedMessages: Array<SavedMessage> = [];
for (const savedMessageRow of savedMessageRows) {
if (savedMessages.length >= limit) break;
savedMessages.push(new SavedMessage(savedMessageRow));
}
return savedMessages;
}
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
const savedMessageRow: SavedMessageRow = {
user_id: userId,
channel_id: channelId,
message_id: messageId,
saved_at: new Date(),
};
await upsertOne(SavedMessages.upsertAll(savedMessageRow));
return new SavedMessage(savedMessageRow);
}
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
await deleteOneOrMany(SavedMessages.deleteByPk({user_id: userId, message_id: messageId}));
}
async deleteAllSavedMessages(userId: UserID): Promise<void> {
await deleteOneOrMany(SavedMessages.delete({where: SavedMessages.where.eq('user_id')}).bind({user_id: userId}));
}
}

View File

@@ -0,0 +1,82 @@
/*
* 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 {MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {ScheduledMessageRow} from '@fluxer/api/src/database/types/UserTypes';
import {ScheduledMessage} from '@fluxer/api/src/models/ScheduledMessage';
import {ScheduledMessages} from '@fluxer/api/src/Tables';
export class ScheduledMessageRepository {
private readonly fetchCql = ScheduledMessages.selectCql({
where: [ScheduledMessages.where.eq('user_id')],
});
async listScheduledMessages(userId: UserID, limit: number = 25): Promise<Array<ScheduledMessage>> {
const rows = await fetchMany<ScheduledMessageRow>(this.fetchCql, {
user_id: userId,
});
const messages = rows.map((row) => ScheduledMessage.fromRow(row));
return messages.sort((a, b) => (b.id > a.id ? 1 : a.id > b.id ? -1 : 0)).slice(0, limit);
}
async getScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<ScheduledMessage | null> {
const row = await fetchOne<ScheduledMessageRow>(
ScheduledMessages.selectCql({
where: [ScheduledMessages.where.eq('user_id'), ScheduledMessages.where.eq('scheduled_message_id')],
}),
{
user_id: userId,
scheduled_message_id: scheduledMessageId,
},
);
return row ? ScheduledMessage.fromRow(row) : null;
}
async upsertScheduledMessage(message: ScheduledMessage, _ttlSeconds: number): Promise<void> {
await upsertOne(ScheduledMessages.upsertAll(message.toRow()));
}
async deleteScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<void> {
await deleteOneOrMany(
ScheduledMessages.deleteByPk({
user_id: userId,
scheduled_message_id: scheduledMessageId,
}),
);
}
async markInvalid(userId: UserID, scheduledMessageId: MessageID, reason: string, ttlSeconds: number): Promise<void> {
await upsertOne(
ScheduledMessages.patchByPkWithTtl(
{
user_id: userId,
scheduled_message_id: scheduledMessageId,
},
{
status: Db.set('invalid'),
status_reason: Db.set(reason),
invalidated_at: Db.set(new Date()),
},
ttlSeconds,
),
);
}
}

View File

@@ -0,0 +1,147 @@
/*
* 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 {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import type {User} from '@fluxer/api/src/models/User';
import {UserAccountRepository as UserAccountCrudRepository} from '@fluxer/api/src/user/repositories/account/UserAccountRepository';
import {UserDeletionRepository} from '@fluxer/api/src/user/repositories/account/UserDeletionRepository';
import {UserGuildRepository} from '@fluxer/api/src/user/repositories/account/UserGuildRepository';
import {UserLookupRepository} from '@fluxer/api/src/user/repositories/account/UserLookupRepository';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
export class UserAccountRepository implements IUserAccountRepository {
private accountRepo: UserAccountCrudRepository;
private lookupRepo: UserLookupRepository;
private deletionRepo: UserDeletionRepository;
private guildRepo: UserGuildRepository;
constructor() {
this.accountRepo = new UserAccountCrudRepository();
this.lookupRepo = new UserLookupRepository(this.accountRepo.findUnique.bind(this.accountRepo));
this.deletionRepo = new UserDeletionRepository(this.accountRepo.findUnique.bind(this.accountRepo));
this.guildRepo = new UserGuildRepository();
}
async create(data: UserRow): Promise<User> {
return this.accountRepo.create(data);
}
async findUnique(userId: UserID): Promise<User | null> {
return this.accountRepo.findUnique(userId);
}
async findUniqueAssert(userId: UserID): Promise<User> {
return this.accountRepo.findUniqueAssert(userId);
}
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
return this.accountRepo.listAllUsersPaginated(limit, lastUserId);
}
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
return this.accountRepo.listUsers(userIds);
}
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
return this.accountRepo.upsert(data, oldData);
}
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User> {
return this.accountRepo.patchUpsert(userId, patchData, oldData);
}
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
return this.accountRepo.deleteUserSecondaryIndices(userId);
}
async findByEmail(email: string): Promise<User | null> {
return this.lookupRepo.findByEmail(email);
}
async findByPhone(phone: string): Promise<User | null> {
return this.lookupRepo.findByPhone(phone);
}
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
return this.lookupRepo.findByStripeCustomerId(stripeCustomerId);
}
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
return this.lookupRepo.findByStripeSubscriptionId(stripeSubscriptionId);
}
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
return this.lookupRepo.findByUsernameDiscriminator(username, discriminator);
}
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
return this.lookupRepo.findDiscriminatorsByUsername(username);
}
async getActivityTracking(userId: UserID): Promise<{last_active_at: Date | null; last_active_ip: string | null}> {
const result = await this.accountRepo.getActivityTracking(userId);
return result ?? {last_active_at: null, last_active_ip: null};
}
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
return this.deletionRepo.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
}
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
return this.deletionRepo.findUsersPendingDeletion(now);
}
async findUsersPendingDeletionByDate(
deletionDate: string,
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
return this.deletionRepo.findUsersPendingDeletionByDate(deletionDate);
}
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
return this.deletionRepo.isUserPendingDeletion(userId, deletionDate);
}
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
return this.deletionRepo.removePendingDeletion(userId, pendingDeletionAt);
}
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
return this.deletionRepo.scheduleDeletion(userId, pendingDeletionAt, deletionReasonCode);
}
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
return this.guildRepo.getUserGuildIds(userId);
}
async removeFromAllGuilds(userId: UserID): Promise<void> {
return this.guildRepo.removeFromAllGuilds(userId);
}
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
return this.accountRepo.updateLastActiveAt(params);
}
async updateSubscriptionStatus(
userId: UserID,
updates: {premiumWillCancel: boolean; computedPremiumUntil: Date | null},
): Promise<{finalVersion: number | null}> {
return this.accountRepo.updateSubscriptionStatus(userId, updates);
}
}

View File

@@ -0,0 +1,227 @@
/*
* 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 {PhoneVerificationToken, UserID} from '@fluxer/api/src/BrandedTypes';
import type {
AuthSessionRow,
EmailRevertTokenRow,
EmailVerificationTokenRow,
PasswordResetTokenRow,
PhoneTokenRow,
} from '@fluxer/api/src/database/types/AuthTypes';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {EmailRevertToken} from '@fluxer/api/src/models/EmailRevertToken';
import type {EmailVerificationToken} from '@fluxer/api/src/models/EmailVerificationToken';
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
import type {PasswordResetToken} from '@fluxer/api/src/models/PasswordResetToken';
import type {WebAuthnCredential} from '@fluxer/api/src/models/WebAuthnCredential';
import {AuthSessionRepository} from '@fluxer/api/src/user/repositories/auth/AuthSessionRepository';
import {IpAuthorizationRepository} from '@fluxer/api/src/user/repositories/auth/IpAuthorizationRepository';
import {MfaBackupCodeRepository} from '@fluxer/api/src/user/repositories/auth/MfaBackupCodeRepository';
import {TokenRepository} from '@fluxer/api/src/user/repositories/auth/TokenRepository';
import {WebAuthnRepository} from '@fluxer/api/src/user/repositories/auth/WebAuthnRepository';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserAuthRepository} from '@fluxer/api/src/user/repositories/IUserAuthRepository';
export class UserAuthRepository implements IUserAuthRepository {
private authSessionRepository: AuthSessionRepository;
private mfaBackupCodeRepository: MfaBackupCodeRepository;
private tokenRepository: TokenRepository;
private ipAuthorizationRepository: IpAuthorizationRepository;
private webAuthnRepository: WebAuthnRepository;
constructor(userAccountRepository: IUserAccountRepository) {
this.authSessionRepository = new AuthSessionRepository();
this.mfaBackupCodeRepository = new MfaBackupCodeRepository();
this.tokenRepository = new TokenRepository();
this.ipAuthorizationRepository = new IpAuthorizationRepository(userAccountRepository);
this.webAuthnRepository = new WebAuthnRepository();
}
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
return this.authSessionRepository.listAuthSessions(userId);
}
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
return this.authSessionRepository.getAuthSessionByToken(sessionIdHash);
}
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
return this.authSessionRepository.createAuthSession(sessionData);
}
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
const session = await this.getAuthSessionByToken(sessionIdHash);
if (!session) return;
await this.authSessionRepository.updateAuthSessionLastUsed(sessionIdHash);
}
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
return this.authSessionRepository.deleteAuthSessions(userId, sessionIdHashes);
}
async revokeAuthSession(sessionIdHash: Buffer): Promise<void> {
const session = await this.getAuthSessionByToken(sessionIdHash);
if (!session) return;
await this.deleteAuthSessions(session.userId, [sessionIdHash]);
}
async deleteAllAuthSessions(userId: UserID): Promise<void> {
return this.authSessionRepository.deleteAllAuthSessions(userId);
}
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
return this.mfaBackupCodeRepository.listMfaBackupCodes(userId);
}
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
return this.mfaBackupCodeRepository.createMfaBackupCodes(userId, codes);
}
async clearMfaBackupCodes(userId: UserID): Promise<void> {
return this.mfaBackupCodeRepository.clearMfaBackupCodes(userId);
}
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
return this.mfaBackupCodeRepository.consumeMfaBackupCode(userId, code);
}
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
return this.mfaBackupCodeRepository.deleteAllMfaBackupCodes(userId);
}
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
return this.tokenRepository.getEmailVerificationToken(token);
}
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
return this.tokenRepository.createEmailVerificationToken(tokenData);
}
async deleteEmailVerificationToken(token: string): Promise<void> {
return this.tokenRepository.deleteEmailVerificationToken(token);
}
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
return this.tokenRepository.getPasswordResetToken(token);
}
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
return this.tokenRepository.createPasswordResetToken(tokenData);
}
async deletePasswordResetToken(token: string): Promise<void> {
return this.tokenRepository.deletePasswordResetToken(token);
}
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
return this.tokenRepository.getEmailRevertToken(token);
}
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
return this.tokenRepository.createEmailRevertToken(tokenData);
}
async deleteEmailRevertToken(token: string): Promise<void> {
return this.tokenRepository.deleteEmailRevertToken(token);
}
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
return this.tokenRepository.createPhoneToken(token, phone, userId);
}
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
return this.tokenRepository.getPhoneToken(token);
}
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
return this.tokenRepository.deletePhoneToken(token);
}
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
return this.ipAuthorizationRepository.updateUserActivity(userId, clientIp);
}
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
return this.ipAuthorizationRepository.checkIpAuthorized(userId, ip);
}
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
return this.ipAuthorizationRepository.createAuthorizedIp(userId, ip);
}
async createIpAuthorizationToken(userId: UserID, token: string, email: string): Promise<void> {
return this.ipAuthorizationRepository.createIpAuthorizationToken(userId, token, email);
}
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
return this.ipAuthorizationRepository.authorizeIpByToken(token);
}
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
return this.ipAuthorizationRepository.getAuthorizedIps(userId);
}
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
return this.ipAuthorizationRepository.deleteAllAuthorizedIps(userId);
}
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
return this.webAuthnRepository.listWebAuthnCredentials(userId);
}
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
return this.webAuthnRepository.getWebAuthnCredential(userId, credentialId);
}
async createWebAuthnCredential(
userId: UserID,
credentialId: string,
publicKey: Buffer,
counter: bigint,
transports: Set<string> | null,
name: string,
): Promise<void> {
return this.webAuthnRepository.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
}
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
return this.webAuthnRepository.updateWebAuthnCredentialCounter(userId, credentialId, counter);
}
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
return this.webAuthnRepository.updateWebAuthnCredentialLastUsed(userId, credentialId);
}
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
return this.webAuthnRepository.updateWebAuthnCredentialName(userId, credentialId, name);
}
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
return this.webAuthnRepository.deleteWebAuthnCredential(userId, credentialId);
}
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
return this.webAuthnRepository.getUserIdByCredentialId(credentialId);
}
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
return this.webAuthnRepository.deleteAllWebAuthnCredentials(userId);
}
}

View File

@@ -0,0 +1,536 @@
/*
* 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 {
BatchBuilder,
deleteOneOrMany,
fetchMany,
fetchManyInChunks,
fetchOne,
upsertOne,
} from '@fluxer/api/src/database/Cassandra';
import type {ChannelRow, DmStateRow, PrivateChannelRow} from '@fluxer/api/src/database/types/ChannelTypes';
import {Channel} from '@fluxer/api/src/models/Channel';
import {Channels, DmStates, PinnedDms, PrivateChannels, ReadStates, UserDmHistory} from '@fluxer/api/src/Tables';
import type {
HistoricalDmChannelSummary,
IUserChannelRepository,
ListHistoricalDmChannelOptions,
PrivateChannelSummary,
} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
interface PinnedDmRow {
user_id: UserID;
channel_id: ChannelID;
sort_order: number;
}
interface ChannelDetailsRow {
channel_id: ChannelID;
type: number;
recipient_ids: Set<UserID> | null;
last_message_id: MessageID | null;
soft_deleted: boolean;
}
const CHECK_PRIVATE_CHANNEL_CQL = PrivateChannels.selectCql({
columns: ['channel_id'],
where: [PrivateChannels.where.eq('user_id'), PrivateChannels.where.eq('channel_id')],
});
const FETCH_CHANNEL_CQL = Channels.selectCql({
columns: [
'channel_id',
'guild_id',
'type',
'name',
'topic',
'icon_hash',
'url',
'parent_id',
'position',
'owner_id',
'recipient_ids',
'nsfw',
'rate_limit_per_user',
'bitrate',
'user_limit',
'rtc_region',
'last_message_id',
'last_pin_timestamp',
'permission_overwrites',
'nicks',
'soft_deleted',
],
where: [Channels.where.eq('channel_id'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
limit: 1,
});
const FETCH_DM_STATE_CQL = DmStates.selectCql({
where: [DmStates.where.eq('hi_user_id'), DmStates.where.eq('lo_user_id')],
limit: 1,
});
const FETCH_PINNED_DMS_CQL = PinnedDms.selectCql({
where: PinnedDms.where.eq('user_id'),
});
const FETCH_PRIVATE_CHANNELS_CQL = PrivateChannels.selectCql({
where: PrivateChannels.where.eq('user_id'),
});
const FETCH_OPEN_PRIVATE_CHANNELS_BY_IDS_CQL = PrivateChannels.selectCql({
columns: ['channel_id'],
where: [PrivateChannels.where.eq('user_id'), PrivateChannels.where.in('channel_id', 'channel_ids')],
});
const HISTORICAL_DM_CHANNELS_CQL = UserDmHistory.selectCql({
columns: ['channel_id'],
where: UserDmHistory.where.eq('user_id'),
});
const FETCH_CHANNEL_METADATA_CQL = Channels.selectCql({
columns: ['channel_id', 'type', 'last_message_id', 'soft_deleted'],
where: [Channels.where.in('channel_id', 'channel_ids'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
});
const FETCH_CHANNEL_DETAILS_CQL = Channels.selectCql({
columns: ['channel_id', 'type', 'recipient_ids', 'last_message_id', 'soft_deleted'],
where: [Channels.where.in('channel_id', 'channel_ids'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
});
const FETCH_CHANNELS_IN_CQL = Channels.selectCql({
where: [Channels.where.in('channel_id', 'channel_ids'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
});
function sortBySortOrder(a: PinnedDmRow, b: PinnedDmRow): number {
return a.sort_order - b.sort_order;
}
async function fetchPinnedDms(userId: UserID): Promise<Array<PinnedDmRow>> {
return fetchMany<PinnedDmRow>(FETCH_PINNED_DMS_CQL, {user_id: userId});
}
export class UserChannelRepository implements IUserChannelRepository {
async addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
const pinnedDms = [...(await fetchPinnedDms(userId))];
const existingDm = pinnedDms.find((dm) => dm.channel_id === channelId);
if (existingDm) {
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
}
let highestSortOrder = -1;
for (const dm of pinnedDms) {
if (dm.sort_order > highestSortOrder) {
highestSortOrder = dm.sort_order;
}
}
const newSortOrder = highestSortOrder + 1;
await upsertOne(
PinnedDms.upsertAll({
user_id: userId,
channel_id: channelId,
sort_order: newSortOrder,
}),
);
pinnedDms.push({
user_id: userId,
channel_id: channelId,
sort_order: newSortOrder,
});
pinnedDms.sort(sortBySortOrder);
return pinnedDms.map((dm) => dm.channel_id);
}
async closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void> {
await deleteOneOrMany(
PrivateChannels.deleteByPk({
user_id: userId,
channel_id: channelId,
}),
);
}
async createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel> {
const hiUserId = user1Id > user2Id ? user1Id : user2Id;
const loUserId = user1Id > user2Id ? user2Id : user1Id;
const batch = new BatchBuilder();
const channelRow: ChannelRow = {
channel_id: channelId,
guild_id: null,
type: ChannelTypes.DM,
name: null,
topic: null,
icon_hash: null,
url: null,
parent_id: null,
position: null,
owner_id: null,
recipient_ids: new Set([user1Id, user2Id]),
nsfw: null,
rate_limit_per_user: null,
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,
};
batch.addPrepared(Channels.upsertAll(channelRow));
batch.addPrepared(
DmStates.upsertAll({
hi_user_id: hiUserId,
lo_user_id: loUserId,
channel_id: channelId,
}),
);
batch.addPrepared(
PrivateChannels.upsertAll({
user_id: user1Id,
channel_id: channelId,
is_gdm: false,
}),
);
await batch.execute();
return new Channel(channelRow);
}
async deleteAllPrivateChannels(userId: UserID): Promise<void> {
await deleteOneOrMany(
PrivateChannels.deleteCql({
where: PrivateChannels.where.eq('user_id'),
}),
{user_id: userId},
);
}
async deleteAllReadStates(userId: UserID): Promise<void> {
await deleteOneOrMany(
ReadStates.deleteCql({
where: ReadStates.where.eq('user_id'),
}),
{user_id: userId},
);
}
async findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null> {
const hiUserId = user1Id > user2Id ? user1Id : user2Id;
const loUserId = user1Id > user2Id ? user2Id : user1Id;
const dmState = await fetchOne<DmStateRow>(FETCH_DM_STATE_CQL, {
hi_user_id: hiUserId,
lo_user_id: loUserId,
});
if (!dmState) {
return null;
}
const channel = await fetchOne<ChannelRow>(FETCH_CHANNEL_CQL, {
channel_id: dmState.channel_id,
soft_deleted: false,
});
return channel ? new Channel(channel) : null;
}
async getPinnedDms(userId: UserID): Promise<Array<ChannelID>> {
const pinnedDms = await fetchPinnedDms(userId);
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
}
async getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>> {
const pinnedDms = await fetchPinnedDms(userId);
return pinnedDms.sort(sortBySortOrder);
}
async isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean> {
const result = await fetchOne<{channel_id: bigint}>(CHECK_PRIVATE_CHANNEL_CQL, {
user_id: userId,
channel_id: channelId,
});
return result != null;
}
async listPrivateChannels(userId: UserID): Promise<Array<Channel>> {
const rows = await fetchMany<PrivateChannelRow>(FETCH_PRIVATE_CHANNELS_CQL, {
user_id: userId,
});
if (rows.length === 0) {
return [];
}
const channelIds = rows.map((row) => row.channel_id);
const channelRows = await fetchManyInChunks<ChannelRow>(FETCH_CHANNELS_IN_CQL, channelIds, (chunk) => ({
channel_ids: chunk,
soft_deleted: false,
}));
return channelRows.map((row) => new Channel(row));
}
async listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>> {
const rows = await fetchMany<PrivateChannelRow>(FETCH_PRIVATE_CHANNELS_CQL, {
user_id: userId,
});
if (rows.length === 0) {
return [];
}
const channelIds = rows.map((row) => row.channel_id);
const fetchMetadataForSoftDeleted = async (
ids: Array<ChannelID>,
softDeleted: boolean,
): Promise<
Array<{
channel_id: ChannelID;
type: number;
last_message_id: MessageID | null;
soft_deleted: boolean;
}>
> => {
return fetchManyInChunks(FETCH_CHANNEL_METADATA_CQL, ids, (chunk) => ({
channel_ids: chunk,
soft_deleted: softDeleted,
}));
};
const channelMap = new Map<
ChannelID,
{
channel_id: ChannelID;
type: number;
last_message_id: MessageID | null;
soft_deleted: boolean;
}
>();
const openChannelRows = await fetchMetadataForSoftDeleted(channelIds, false);
for (const row of openChannelRows) {
channelMap.set(row.channel_id, row);
}
const missingChannelIds = channelIds.filter((id) => !channelMap.has(id));
if (missingChannelIds.length > 0) {
const deletedChannelRows = await fetchMetadataForSoftDeleted(missingChannelIds, true);
for (const row of deletedChannelRows) {
if (!channelMap.has(row.channel_id)) {
channelMap.set(row.channel_id, row);
}
}
}
return rows.map((row) => {
const channelRow = channelMap.get(row.channel_id);
return {
channelId: row.channel_id,
isGroupDm: row.is_gdm ?? false,
channelType: channelRow ? channelRow.type : null,
lastMessageId: channelRow ? channelRow.last_message_id : null,
open: Boolean(channelRow && !channelRow.soft_deleted),
};
});
}
async listHistoricalDmChannelIds(userId: UserID): Promise<Array<ChannelID>> {
const rows = await fetchMany<{channel_id: ChannelID}>(HISTORICAL_DM_CHANNELS_CQL, {
user_id: userId,
});
return rows.map((row) => row.channel_id);
}
async listHistoricalDmChannelsPaginated(
userId: UserID,
options: ListHistoricalDmChannelOptions,
): Promise<Array<HistoricalDmChannelSummary>> {
if (options.beforeChannelId !== undefined && options.afterChannelId !== undefined) {
throw new Error('Cannot paginate with both beforeChannelId and afterChannelId');
}
let rows: Array<{channel_id: ChannelID}>;
if (options.afterChannelId !== undefined) {
const query = UserDmHistory.select({
columns: ['channel_id'],
where: [UserDmHistory.where.eq('user_id'), UserDmHistory.where.gt('channel_id', 'after_channel_id')],
orderBy: {col: 'channel_id', direction: 'ASC'},
limit: options.limit,
});
rows = await fetchMany<{channel_id: ChannelID}>(
query.bind({
user_id: userId,
after_channel_id: options.afterChannelId,
}),
);
rows.reverse();
} else if (options.beforeChannelId !== undefined) {
const query = UserDmHistory.select({
columns: ['channel_id'],
where: [UserDmHistory.where.eq('user_id'), UserDmHistory.where.lt('channel_id', 'before_channel_id')],
orderBy: {col: 'channel_id', direction: 'DESC'},
limit: options.limit,
});
rows = await fetchMany<{channel_id: ChannelID}>(
query.bind({
user_id: userId,
before_channel_id: options.beforeChannelId,
}),
);
} else {
const query = UserDmHistory.select({
columns: ['channel_id'],
where: UserDmHistory.where.eq('user_id'),
orderBy: {col: 'channel_id', direction: 'DESC'},
limit: options.limit,
});
rows = await fetchMany<{channel_id: ChannelID}>(
query.bind({
user_id: userId,
}),
);
}
const channelIds = rows.map((row) => row.channel_id);
if (channelIds.length === 0) {
return [];
}
const openChannelRows = await fetchManyInChunks<{channel_id: ChannelID}>(
FETCH_OPEN_PRIVATE_CHANNELS_BY_IDS_CQL,
channelIds,
(chunk) => ({
user_id: userId,
channel_ids: chunk,
}),
);
const openChannelIds = new Set(openChannelRows.map((row) => row.channel_id));
const fetchChannelDetails = async (
ids: Array<ChannelID>,
softDeleted: boolean,
): Promise<Array<ChannelDetailsRow>> => {
return fetchManyInChunks<ChannelDetailsRow>(FETCH_CHANNEL_DETAILS_CQL, ids, (chunk) => ({
channel_ids: chunk,
soft_deleted: softDeleted,
}));
};
const channelMap = new Map<ChannelID, ChannelDetailsRow>();
const nonDeletedChannels = await fetchChannelDetails(channelIds, false);
for (const channel of nonDeletedChannels) {
channelMap.set(channel.channel_id, channel);
}
const missingChannelIds = channelIds.filter((channelId) => !channelMap.has(channelId));
if (missingChannelIds.length > 0) {
const deletedChannels = await fetchChannelDetails(missingChannelIds, true);
for (const channel of deletedChannels) {
if (!channelMap.has(channel.channel_id)) {
channelMap.set(channel.channel_id, channel);
}
}
}
return channelIds.map((channelId) => {
const channel = channelMap.get(channelId);
return {
channelId,
channelType: channel?.type ?? null,
recipientIds: channel?.recipient_ids ? Array.from(channel.recipient_ids) : [],
lastMessageId: channel?.last_message_id ?? null,
open: openChannelIds.has(channelId),
};
});
}
async openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void> {
let resolvedIsGroupDm: boolean;
if (isGroupDm !== undefined) {
resolvedIsGroupDm = isGroupDm;
} else {
const channelRow = await fetchOne<ChannelRow>(FETCH_CHANNEL_CQL, {
channel_id: channelId,
soft_deleted: false,
});
resolvedIsGroupDm = channelRow?.type === ChannelTypes.GROUP_DM;
}
await this.recordHistoricalDmChannel(userId, channelId, resolvedIsGroupDm);
await upsertOne(
PrivateChannels.upsertAll({
user_id: userId,
channel_id: channelId,
is_gdm: resolvedIsGroupDm,
}),
);
}
async recordHistoricalDmChannel(userId: UserID, channelId: ChannelID, isGroupDm: boolean): Promise<void> {
if (isGroupDm) {
return;
}
await upsertOne(
UserDmHistory.upsertAll({
user_id: userId,
channel_id: channelId,
}),
);
}
async removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
await deleteOneOrMany(
PinnedDms.deleteByPk({
user_id: userId,
channel_id: channelId,
}),
);
const pinnedDms = await fetchPinnedDms(userId);
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
}
async deletePinnedDmsByUserId(userId: UserID): Promise<void> {
await deleteOneOrMany(
PinnedDms.deleteCql({
where: PinnedDms.where.eq('user_id'),
}),
{user_id: userId},
);
}
}

View File

@@ -0,0 +1,90 @@
/*
* 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 {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
import {fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {UserContactChangeLogRow} from '@fluxer/api/src/database/types/UserTypes';
import {UserContactChangeLogs} from '@fluxer/api/src/Tables';
const createListLogsCql = (limit: number, includeCursor: boolean) =>
UserContactChangeLogs.selectCql({
where: includeCursor
? [UserContactChangeLogs.where.eq('user_id'), UserContactChangeLogs.where.lt('event_id', 'before_event_id')]
: UserContactChangeLogs.where.eq('user_id'),
orderBy: {col: 'event_id', direction: 'DESC'},
limit,
});
export interface ContactChangeLogListParams {
userId: UserID;
limit: number;
beforeEventId?: string;
}
export interface ContactChangeLogInsertParams {
userId: UserID;
field: string;
oldValue: string | null;
newValue: string | null;
reason: string;
actorUserId: UserID | null;
eventAt?: Date;
}
export class UserContactChangeLogRepository {
async insertLog(params: ContactChangeLogInsertParams): Promise<void> {
const eventAt = params.eventAt ?? new Date();
await upsertOne(
UserContactChangeLogs.insertWithNow(
{
user_id: params.userId,
field: params.field,
old_value: params.oldValue,
new_value: params.newValue,
reason: params.reason,
actor_user_id: params.actorUserId,
event_at: eventAt,
},
'event_id',
),
);
}
async listLogs(params: ContactChangeLogListParams): Promise<Array<UserContactChangeLogRow>> {
const {userId, limit, beforeEventId} = params;
const query = createListLogsCql(limit, !!beforeEventId);
const queryParams: {user_id: UserID; before_event_id?: string} = {
user_id: userId,
};
if (beforeEventId) {
queryParams.before_event_id = beforeEventId;
}
const rows = await fetchMany<
Omit<UserContactChangeLogRow, 'user_id' | 'actor_user_id'> & {
user_id: bigint;
actor_user_id: bigint | null;
}
>(query, queryParams);
return rows.map((row) => ({
...row,
user_id: createUserID(row.user_id),
actor_user_id: row.actor_user_id != null ? createUserID(row.actor_user_id) : null,
}));
}
}

View File

@@ -0,0 +1,203 @@
/*
* 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 type {GiftCodeRow, PaymentBySubscriptionRow, PaymentRow} from '@fluxer/api/src/database/types/PaymentTypes';
import type {PushSubscriptionRow, RecentMentionRow} from '@fluxer/api/src/database/types/UserTypes';
import type {GiftCode} from '@fluxer/api/src/models/GiftCode';
import type {Payment} from '@fluxer/api/src/models/Payment';
import type {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
import type {RecentMention} from '@fluxer/api/src/models/RecentMention';
import type {SavedMessage} from '@fluxer/api/src/models/SavedMessage';
import type {VisionarySlot} from '@fluxer/api/src/models/VisionarySlot';
import {GiftCodeRepository} from '@fluxer/api/src/user/repositories/GiftCodeRepository';
import type {IUserContentRepository} from '@fluxer/api/src/user/repositories/IUserContentRepository';
import {PaymentRepository} from '@fluxer/api/src/user/repositories/PaymentRepository';
import {PushSubscriptionRepository} from '@fluxer/api/src/user/repositories/PushSubscriptionRepository';
import {RecentMentionRepository} from '@fluxer/api/src/user/repositories/RecentMentionRepository';
import {SavedMessageRepository} from '@fluxer/api/src/user/repositories/SavedMessageRepository';
import {VisionarySlotRepository} from '@fluxer/api/src/user/repositories/VisionarySlotRepository';
export class UserContentRepository implements IUserContentRepository {
private giftCodeRepository: GiftCodeRepository;
private paymentRepository: PaymentRepository;
private pushSubscriptionRepository: PushSubscriptionRepository;
private recentMentionRepository: RecentMentionRepository;
private savedMessageRepository: SavedMessageRepository;
private visionarySlotRepository: VisionarySlotRepository;
constructor() {
this.giftCodeRepository = new GiftCodeRepository();
this.paymentRepository = new PaymentRepository();
this.pushSubscriptionRepository = new PushSubscriptionRepository();
this.recentMentionRepository = new RecentMentionRepository();
this.savedMessageRepository = new SavedMessageRepository();
this.visionarySlotRepository = new VisionarySlotRepository();
}
async createGiftCode(data: GiftCodeRow): Promise<void> {
return this.giftCodeRepository.createGiftCode(data);
}
async findGiftCode(code: string): Promise<GiftCode | null> {
return this.giftCodeRepository.findGiftCode(code);
}
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
return this.giftCodeRepository.findGiftCodeByPaymentIntent(paymentIntentId);
}
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
return this.giftCodeRepository.findGiftCodesByCreator(userId);
}
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
return this.giftCodeRepository.redeemGiftCode(code, userId);
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
return this.giftCodeRepository.updateGiftCode(code, data);
}
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
return this.giftCodeRepository.linkGiftCodeToCheckoutSession(code, checkoutSessionId);
}
async createPayment(data: {
checkout_session_id: string;
user_id: UserID;
price_id: string;
product_type: string;
status: string;
is_gift: boolean;
created_at: Date;
}): Promise<void> {
return this.paymentRepository.createPayment(data);
}
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
return this.paymentRepository.updatePayment(data);
}
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
return this.paymentRepository.getPaymentByCheckoutSession(checkoutSessionId);
}
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
return this.paymentRepository.getPaymentByPaymentIntent(paymentIntentId);
}
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
return this.paymentRepository.getSubscriptionInfo(subscriptionId);
}
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
return this.pushSubscriptionRepository.listPushSubscriptions(userId);
}
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
return this.pushSubscriptionRepository.createPushSubscription(data);
}
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
return this.pushSubscriptionRepository.deletePushSubscription(userId, subscriptionId);
}
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
return this.pushSubscriptionRepository.getBulkPushSubscriptions(userIds);
}
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
return this.pushSubscriptionRepository.deleteAllPushSubscriptions(userId);
}
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
return this.recentMentionRepository.getRecentMention(userId, messageId);
}
async listRecentMentions(
userId: UserID,
includeEveryone: boolean = true,
includeRole: boolean = true,
includeGuilds: boolean = true,
limit: number = 25,
before?: MessageID,
): Promise<Array<RecentMention>> {
return this.recentMentionRepository.listRecentMentions(
userId,
includeEveryone,
includeRole,
includeGuilds,
limit,
before,
);
}
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
return this.recentMentionRepository.createRecentMention(mention);
}
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
return this.recentMentionRepository.createRecentMentions(mentions);
}
async deleteRecentMention(mention: RecentMention): Promise<void> {
return this.recentMentionRepository.deleteRecentMention(mention);
}
async deleteAllRecentMentions(userId: UserID): Promise<void> {
return this.recentMentionRepository.deleteAllRecentMentions(userId);
}
async listSavedMessages(userId: UserID, limit: number = 25, before?: MessageID): Promise<Array<SavedMessage>> {
return this.savedMessageRepository.listSavedMessages(userId, limit, before);
}
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
return this.savedMessageRepository.createSavedMessage(userId, channelId, messageId);
}
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
return this.savedMessageRepository.deleteSavedMessage(userId, messageId);
}
async deleteAllSavedMessages(userId: UserID): Promise<void> {
return this.savedMessageRepository.deleteAllSavedMessages(userId);
}
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
return this.visionarySlotRepository.listVisionarySlots();
}
async expandVisionarySlots(byCount: number): Promise<void> {
return this.visionarySlotRepository.expandVisionarySlots(byCount);
}
async shrinkVisionarySlots(toCount: number): Promise<void> {
return this.visionarySlotRepository.shrinkVisionarySlots(toCount);
}
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
return this.visionarySlotRepository.reserveVisionarySlot(slotIndex, userId);
}
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
return this.visionarySlotRepository.unreserveVisionarySlot(slotIndex, userId);
}
}

View File

@@ -0,0 +1,304 @@
/*
* 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 {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
import {
BatchBuilder,
Db,
deleteOneOrMany,
executeVersionedUpdate,
fetchMany,
fetchOne,
nextVersion,
} from '@fluxer/api/src/database/Cassandra';
import type {NoteRow, RelationshipRow} from '@fluxer/api/src/database/types/UserTypes';
import {Relationship} from '@fluxer/api/src/models/Relationship';
import {UserNote} from '@fluxer/api/src/models/UserNote';
import {Notes, Relationships, RelationshipsByTarget} from '@fluxer/api/src/Tables';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
const FETCH_ALL_NOTES_CQL = Notes.selectCql({
where: Notes.where.eq('source_user_id'),
});
const FETCH_NOTE_CQL = Notes.selectCql({
where: [Notes.where.eq('source_user_id'), Notes.where.eq('target_user_id')],
limit: 1,
});
const FETCH_RELATIONSHIPS_CQL = Relationships.selectCql({
where: Relationships.where.eq('source_user_id'),
});
const FETCH_RELATIONSHIP_CQL = Relationships.selectCql({
where: [
Relationships.where.eq('source_user_id'),
Relationships.where.eq('target_user_id'),
Relationships.where.eq('type'),
],
limit: 1,
});
const FETCH_ALL_NOTES_FOR_DELETE_QUERY = Notes.selectCql({
columns: ['source_user_id', 'target_user_id'],
limit: 10000,
});
export class UserRelationshipRepository implements IUserRelationshipRepository {
async clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void> {
await deleteOneOrMany(
Notes.deleteByPk({
source_user_id: sourceUserId,
target_user_id: targetUserId,
}),
);
}
async deleteAllNotes(userId: UserID): Promise<void> {
await deleteOneOrMany(
Notes.deleteCql({
where: Notes.where.eq('source_user_id', 'user_id'),
}),
{user_id: userId},
);
const allNotes = await fetchMany<{source_user_id: bigint; target_user_id: bigint}>(
FETCH_ALL_NOTES_FOR_DELETE_QUERY,
{},
);
const batch = new BatchBuilder();
for (const note of allNotes) {
if (note.target_user_id === BigInt(userId)) {
batch.addPrepared(
Notes.deleteByPk({
source_user_id: createUserID(note.source_user_id),
target_user_id: createUserID(note.target_user_id),
}),
);
}
}
if (batch) {
await batch.execute();
}
}
async deleteAllRelationships(userId: UserID): Promise<void> {
const FETCH_RELATIONSHIPS_BY_TARGET_CQL = RelationshipsByTarget.selectCql({
where: RelationshipsByTarget.where.eq('target_user_id'),
});
const relationshipsPointingToUser = await fetchMany<RelationshipRow>(FETCH_RELATIONSHIPS_BY_TARGET_CQL, {
target_user_id: userId,
});
if (relationshipsPointingToUser.length > 0) {
const batch = new BatchBuilder();
for (const rel of relationshipsPointingToUser) {
batch.addPrepared(
Relationships.deleteByPk({
source_user_id: rel.source_user_id,
target_user_id: rel.target_user_id,
type: rel.type,
}),
);
}
await batch.execute();
}
await deleteOneOrMany(
RelationshipsByTarget.deleteCql({
where: RelationshipsByTarget.where.eq('target_user_id', 'user_id'),
}),
{user_id: userId},
);
await deleteOneOrMany(
Relationships.deleteCql({
where: Relationships.where.eq('source_user_id', 'user_id'),
}),
{user_id: userId},
);
}
async deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void> {
await Promise.all([
deleteOneOrMany(
Relationships.deleteByPk({
source_user_id: sourceUserId,
target_user_id: targetUserId,
type,
}),
),
deleteOneOrMany(
RelationshipsByTarget.deleteByPk({
target_user_id: targetUserId,
source_user_id: sourceUserId,
type,
}),
),
]);
}
async getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null> {
const relationship = await fetchOne<RelationshipRow>(FETCH_RELATIONSHIP_CQL, {
source_user_id: sourceUserId,
target_user_id: targetUserId,
type,
});
return relationship ? new Relationship(relationship) : null;
}
async getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null> {
const note = await fetchOne<NoteRow>(FETCH_NOTE_CQL, {
source_user_id: sourceUserId,
target_user_id: targetUserId,
});
return note ? new UserNote(note) : null;
}
async getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>> {
const notes = await fetchMany<NoteRow>(FETCH_ALL_NOTES_CQL, {source_user_id: sourceUserId});
const noteMap = new Map<UserID, string>();
for (const note of notes) {
noteMap.set(note.target_user_id, note.note);
}
return noteMap;
}
async listRelationships(sourceUserId: UserID): Promise<Array<Relationship>> {
const relationships = await fetchMany<RelationshipRow>(FETCH_RELATIONSHIPS_CQL, {
source_user_id: sourceUserId,
});
return relationships.map((rel) => new Relationship(rel));
}
async hasReachedRelationshipLimit(sourceUserId: UserID, limit: number): Promise<boolean> {
const relationships = await fetchMany<RelationshipRow>(
Relationships.selectCql({
where: Relationships.where.eq('source_user_id'),
limit: limit + 1,
}),
{source_user_id: sourceUserId},
);
return relationships.length >= limit;
}
async upsertRelationship(relationship: RelationshipRow): Promise<Relationship> {
const result = await executeVersionedUpdate<RelationshipRow, 'source_user_id' | 'target_user_id' | 'type'>(
() =>
fetchOne(FETCH_RELATIONSHIP_CQL, {
source_user_id: relationship.source_user_id,
target_user_id: relationship.target_user_id,
type: relationship.type,
}),
(current) => ({
pk: {
source_user_id: relationship.source_user_id,
target_user_id: relationship.target_user_id,
type: relationship.type,
},
patch: {
nickname: Db.set(relationship.nickname),
since: Db.set(relationship.since),
version: Db.set(nextVersion(current?.version)),
},
}),
Relationships,
);
const finalRelationship: RelationshipRow = {
...relationship,
version: result.finalVersion ?? 1,
};
await executeVersionedUpdate<RelationshipRow, 'target_user_id' | 'source_user_id' | 'type'>(
() =>
fetchOne(
RelationshipsByTarget.selectCql({
where: [
RelationshipsByTarget.where.eq('target_user_id'),
RelationshipsByTarget.where.eq('source_user_id'),
RelationshipsByTarget.where.eq('type'),
],
limit: 1,
}),
{
target_user_id: relationship.target_user_id,
source_user_id: relationship.source_user_id,
type: relationship.type,
},
),
(current) => ({
pk: {
target_user_id: relationship.target_user_id,
source_user_id: relationship.source_user_id,
type: relationship.type,
},
patch: {
nickname: Db.set(relationship.nickname),
since: Db.set(relationship.since),
version: Db.set(nextVersion(current?.version)),
},
}),
RelationshipsByTarget,
);
return new Relationship(finalRelationship);
}
async upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote> {
const result = await executeVersionedUpdate<NoteRow, 'source_user_id' | 'target_user_id'>(
() => fetchOne(FETCH_NOTE_CQL, {source_user_id: sourceUserId, target_user_id: targetUserId}),
(current) => ({
pk: {source_user_id: sourceUserId, target_user_id: targetUserId},
patch: {note: Db.set(note), version: Db.set(nextVersion(current?.version))},
}),
Notes,
);
return new UserNote({
source_user_id: sourceUserId,
target_user_id: targetUserId,
note,
version: result.finalVersion ?? 1,
});
}
async backfillRelationshipsIndex(_userId: UserID, relationships: Array<Relationship>): Promise<void> {
if (relationships.length === 0) {
return;
}
const batch = new BatchBuilder();
for (const rel of relationships) {
const row = rel.toRow();
batch.addPrepared(
RelationshipsByTarget.upsertAll({
target_user_id: row.target_user_id,
source_user_id: row.source_user_id,
type: row.type,
nickname: row.nickname,
since: row.since,
version: row.version,
}),
);
}
await batch.execute();
}
}

View File

@@ -0,0 +1,658 @@
/*
* 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, GuildID, MessageID, PhoneVerificationToken, UserID} from '@fluxer/api/src/BrandedTypes';
import type {
AuthSessionRow,
EmailRevertTokenRow,
EmailVerificationTokenRow,
PasswordResetTokenRow,
PhoneTokenRow,
} from '@fluxer/api/src/database/types/AuthTypes';
import type {GiftCodeRow, PaymentBySubscriptionRow, PaymentRow} from '@fluxer/api/src/database/types/PaymentTypes';
import type {
PushSubscriptionRow,
RecentMentionRow,
RelationshipRow,
UserGuildSettingsRow,
UserRow,
UserSettingsRow,
} from '@fluxer/api/src/database/types/UserTypes';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {Channel} from '@fluxer/api/src/models/Channel';
import type {EmailRevertToken} from '@fluxer/api/src/models/EmailRevertToken';
import type {EmailVerificationToken} from '@fluxer/api/src/models/EmailVerificationToken';
import type {GiftCode} from '@fluxer/api/src/models/GiftCode';
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
import type {PasswordResetToken} from '@fluxer/api/src/models/PasswordResetToken';
import type {Payment} from '@fluxer/api/src/models/Payment';
import type {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
import type {ReadState} from '@fluxer/api/src/models/ReadState';
import type {RecentMention} from '@fluxer/api/src/models/RecentMention';
import type {Relationship} from '@fluxer/api/src/models/Relationship';
import type {SavedMessage} from '@fluxer/api/src/models/SavedMessage';
import type {User} from '@fluxer/api/src/models/User';
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import type {UserNote} from '@fluxer/api/src/models/UserNote';
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
import type {VisionarySlot} from '@fluxer/api/src/models/VisionarySlot';
import type {WebAuthnCredential} from '@fluxer/api/src/models/WebAuthnCredential';
import {ReadStateRepository} from '@fluxer/api/src/read_state/ReadStateRepository';
import type {
HistoricalDmChannelSummary,
ListHistoricalDmChannelOptions,
PrivateChannelSummary,
} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
import type {IUserRepositoryAggregate} from '@fluxer/api/src/user/repositories/IUserRepositoryAggregate';
import {UserAccountRepository} from '@fluxer/api/src/user/repositories/UserAccountRepository';
import {UserAuthRepository} from '@fluxer/api/src/user/repositories/UserAuthRepository';
import {UserChannelRepository} from '@fluxer/api/src/user/repositories/UserChannelRepository';
import {UserContentRepository} from '@fluxer/api/src/user/repositories/UserContentRepository';
import {UserRelationshipRepository} from '@fluxer/api/src/user/repositories/UserRelationshipRepository';
import {UserSettingsRepository} from '@fluxer/api/src/user/repositories/UserSettingsRepository';
export class UserRepository implements IUserRepositoryAggregate {
private accountRepo: UserAccountRepository;
private settingsRepo: UserSettingsRepository;
private authRepo: UserAuthRepository;
private relationshipRepo: UserRelationshipRepository;
private channelRepo: UserChannelRepository;
private contentRepo: UserContentRepository;
private readStateRepo: ReadStateRepository;
constructor() {
this.accountRepo = new UserAccountRepository();
this.settingsRepo = new UserSettingsRepository();
this.authRepo = new UserAuthRepository(this.accountRepo);
this.relationshipRepo = new UserRelationshipRepository();
this.channelRepo = new UserChannelRepository();
this.contentRepo = new UserContentRepository();
this.readStateRepo = new ReadStateRepository();
}
async create(data: UserRow): Promise<User> {
return this.accountRepo.create(data);
}
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
return this.accountRepo.upsert(data, oldData);
}
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User> {
return this.accountRepo.patchUpsert(userId, patchData, oldData);
}
async findUnique(userId: UserID): Promise<User | null> {
return this.accountRepo.findUnique(userId);
}
async findUniqueAssert(userId: UserID): Promise<User> {
return this.accountRepo.findUniqueAssert(userId);
}
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
return this.accountRepo.findByUsernameDiscriminator(username, discriminator);
}
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
return this.accountRepo.findDiscriminatorsByUsername(username);
}
async findByEmail(email: string): Promise<User | null> {
return this.accountRepo.findByEmail(email);
}
async findByPhone(phone: string): Promise<User | null> {
return this.accountRepo.findByPhone(phone);
}
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
return this.accountRepo.findByStripeSubscriptionId(stripeSubscriptionId);
}
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
return this.accountRepo.findByStripeCustomerId(stripeCustomerId);
}
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
return this.accountRepo.listUsers(userIds);
}
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
return this.accountRepo.listAllUsersPaginated(limit, lastUserId);
}
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
return this.accountRepo.getUserGuildIds(userId);
}
async getActivityTracking(userId: UserID): Promise<{last_active_at: Date | null; last_active_ip: string | null}> {
return this.accountRepo.getActivityTracking(userId);
}
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
return this.accountRepo.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
}
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
return this.accountRepo.removePendingDeletion(userId, pendingDeletionAt);
}
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
return this.accountRepo.findUsersPendingDeletion(now);
}
async findUsersPendingDeletionByDate(
deletionDate: string,
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
return this.accountRepo.findUsersPendingDeletionByDate(deletionDate);
}
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
return this.accountRepo.isUserPendingDeletion(userId, deletionDate);
}
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
return this.accountRepo.scheduleDeletion(userId, pendingDeletionAt, deletionReasonCode);
}
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
return this.accountRepo.deleteUserSecondaryIndices(userId);
}
async removeFromAllGuilds(userId: UserID): Promise<void> {
return this.accountRepo.removeFromAllGuilds(userId);
}
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
return this.accountRepo.updateLastActiveAt(params);
}
async updateSubscriptionStatus(
userId: UserID,
updates: {premiumWillCancel: boolean; computedPremiumUntil: Date | null},
): Promise<{finalVersion: number | null}> {
return this.accountRepo.updateSubscriptionStatus(userId, updates);
}
async findSettings(userId: UserID): Promise<UserSettings | null> {
return this.settingsRepo.findSettings(userId);
}
async upsertSettings(settings: UserSettingsRow): Promise<UserSettings> {
return this.settingsRepo.upsertSettings(settings);
}
async deleteUserSettings(userId: UserID): Promise<void> {
return this.settingsRepo.deleteUserSettings(userId);
}
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
return this.settingsRepo.findGuildSettings(userId, guildId);
}
async findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>> {
return this.settingsRepo.findAllGuildSettings(userId);
}
async upsertGuildSettings(settings: UserGuildSettingsRow): Promise<UserGuildSettings> {
return this.settingsRepo.upsertGuildSettings(settings);
}
async deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void> {
return this.settingsRepo.deleteGuildSettings(userId, guildId);
}
async deleteAllUserGuildSettings(userId: UserID): Promise<void> {
return this.settingsRepo.deleteAllUserGuildSettings(userId);
}
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
return this.authRepo.listAuthSessions(userId);
}
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
return this.authRepo.getAuthSessionByToken(sessionIdHash);
}
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
return this.authRepo.createAuthSession(sessionData);
}
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
return this.authRepo.updateAuthSessionLastUsed(sessionIdHash);
}
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
return this.authRepo.deleteAuthSessions(userId, sessionIdHashes);
}
async revokeAuthSession(sessionIdHash: Buffer): Promise<void> {
return this.authRepo.revokeAuthSession(sessionIdHash);
}
async deleteAllAuthSessions(userId: UserID): Promise<void> {
return this.authRepo.deleteAllAuthSessions(userId);
}
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
return this.authRepo.listMfaBackupCodes(userId);
}
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
return this.authRepo.createMfaBackupCodes(userId, codes);
}
async clearMfaBackupCodes(userId: UserID): Promise<void> {
return this.authRepo.clearMfaBackupCodes(userId);
}
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
return this.authRepo.consumeMfaBackupCode(userId, code);
}
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
return this.authRepo.deleteAllMfaBackupCodes(userId);
}
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
return this.authRepo.getEmailVerificationToken(token);
}
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
return this.authRepo.createEmailVerificationToken(tokenData);
}
async deleteEmailVerificationToken(token: string): Promise<void> {
return this.authRepo.deleteEmailVerificationToken(token);
}
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
return this.authRepo.getPasswordResetToken(token);
}
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
return this.authRepo.createPasswordResetToken(tokenData);
}
async deletePasswordResetToken(token: string): Promise<void> {
return this.authRepo.deletePasswordResetToken(token);
}
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
return this.authRepo.getEmailRevertToken(token);
}
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
return this.authRepo.createEmailRevertToken(tokenData);
}
async deleteEmailRevertToken(token: string): Promise<void> {
return this.authRepo.deleteEmailRevertToken(token);
}
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
return this.authRepo.createPhoneToken(token, phone, userId);
}
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
return this.authRepo.getPhoneToken(token);
}
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
return this.authRepo.deletePhoneToken(token);
}
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
return this.authRepo.updateUserActivity(userId, clientIp);
}
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
return this.authRepo.checkIpAuthorized(userId, ip);
}
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
return this.authRepo.createAuthorizedIp(userId, ip);
}
async createIpAuthorizationToken(userId: UserID, token: string, email: string): Promise<void> {
return this.authRepo.createIpAuthorizationToken(userId, token, email);
}
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
return this.authRepo.authorizeIpByToken(token);
}
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
return this.authRepo.getAuthorizedIps(userId);
}
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
return this.authRepo.deleteAllAuthorizedIps(userId);
}
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
return this.authRepo.listWebAuthnCredentials(userId);
}
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
return this.authRepo.getWebAuthnCredential(userId, credentialId);
}
async createWebAuthnCredential(
userId: UserID,
credentialId: string,
publicKey: Buffer,
counter: bigint,
transports: Set<string> | null,
name: string,
): Promise<void> {
return this.authRepo.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
}
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
return this.authRepo.updateWebAuthnCredentialCounter(userId, credentialId, counter);
}
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
return this.authRepo.updateWebAuthnCredentialLastUsed(userId, credentialId);
}
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
return this.authRepo.updateWebAuthnCredentialName(userId, credentialId, name);
}
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
return this.authRepo.deleteWebAuthnCredential(userId, credentialId);
}
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
return this.authRepo.getUserIdByCredentialId(credentialId);
}
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
return this.authRepo.deleteAllWebAuthnCredentials(userId);
}
async listRelationships(sourceUserId: UserID): Promise<Array<Relationship>> {
return this.relationshipRepo.listRelationships(sourceUserId);
}
async hasReachedRelationshipLimit(sourceUserId: UserID, limit: number): Promise<boolean> {
return this.relationshipRepo.hasReachedRelationshipLimit(sourceUserId, limit);
}
async getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null> {
return this.relationshipRepo.getRelationship(sourceUserId, targetUserId, type);
}
async upsertRelationship(relationship: RelationshipRow): Promise<Relationship> {
return this.relationshipRepo.upsertRelationship(relationship);
}
async deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void> {
return this.relationshipRepo.deleteRelationship(sourceUserId, targetUserId, type);
}
async deleteAllRelationships(userId: UserID): Promise<void> {
return this.relationshipRepo.deleteAllRelationships(userId);
}
async backfillRelationshipsIndex(userId: UserID, relationships: Array<Relationship>): Promise<void> {
return this.relationshipRepo.backfillRelationshipsIndex(userId, relationships);
}
async getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null> {
return this.relationshipRepo.getUserNote(sourceUserId, targetUserId);
}
async getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>> {
return this.relationshipRepo.getUserNotes(sourceUserId);
}
async upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote> {
return this.relationshipRepo.upsertUserNote(sourceUserId, targetUserId, note);
}
async clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void> {
return this.relationshipRepo.clearUserNote(sourceUserId, targetUserId);
}
async deleteAllNotes(userId: UserID): Promise<void> {
return this.relationshipRepo.deleteAllNotes(userId);
}
async listPrivateChannels(userId: UserID): Promise<Array<Channel>> {
return this.channelRepo.listPrivateChannels(userId);
}
async listHistoricalDmChannelIds(userId: UserID): Promise<Array<ChannelID>> {
return this.channelRepo.listHistoricalDmChannelIds(userId);
}
async listHistoricalDmChannelsPaginated(
userId: UserID,
options: ListHistoricalDmChannelOptions,
): Promise<Array<HistoricalDmChannelSummary>> {
return this.channelRepo.listHistoricalDmChannelsPaginated(userId, options);
}
async recordHistoricalDmChannel(userId: UserID, channelId: ChannelID, isGroupDm: boolean): Promise<void> {
return this.channelRepo.recordHistoricalDmChannel(userId, channelId, isGroupDm);
}
async listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>> {
return this.channelRepo.listPrivateChannelSummaries(userId);
}
async deleteAllPrivateChannels(userId: UserID): Promise<void> {
return this.channelRepo.deleteAllPrivateChannels(userId);
}
async findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null> {
return this.channelRepo.findExistingDmState(user1Id, user2Id);
}
async createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel> {
return this.channelRepo.createDmChannelAndState(user1Id, user2Id, channelId);
}
async isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean> {
return this.channelRepo.isDmChannelOpen(userId, channelId);
}
async openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void> {
return this.channelRepo.openDmForUser(userId, channelId, isGroupDm);
}
async closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void> {
return this.channelRepo.closeDmForUser(userId, channelId);
}
async getPinnedDms(userId: UserID): Promise<Array<ChannelID>> {
return this.channelRepo.getPinnedDms(userId);
}
async getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>> {
return this.channelRepo.getPinnedDmsWithDetails(userId);
}
async addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
return this.channelRepo.addPinnedDm(userId, channelId);
}
async removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
return this.channelRepo.removePinnedDm(userId, channelId);
}
async deletePinnedDmsByUserId(userId: UserID): Promise<void> {
return this.channelRepo.deletePinnedDmsByUserId(userId);
}
async deleteAllReadStates(userId: UserID): Promise<void> {
return this.channelRepo.deleteAllReadStates(userId);
}
async getReadStates(userId: UserID): Promise<Array<ReadState>> {
return this.readStateRepo.listReadStates(userId);
}
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
return this.contentRepo.getRecentMention(userId, messageId);
}
async listRecentMentions(
userId: UserID,
includeEveryone: boolean,
includeRole: boolean,
includeGuilds: boolean,
limit: number,
before?: MessageID,
): Promise<Array<RecentMention>> {
return this.contentRepo.listRecentMentions(userId, includeEveryone, includeRole, includeGuilds, limit, before);
}
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
return this.contentRepo.createRecentMention(mention);
}
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
return this.contentRepo.createRecentMentions(mentions);
}
async deleteRecentMention(mention: RecentMention): Promise<void> {
return this.contentRepo.deleteRecentMention(mention);
}
async deleteAllRecentMentions(userId: UserID): Promise<void> {
return this.contentRepo.deleteAllRecentMentions(userId);
}
async listSavedMessages(userId: UserID, limit?: number, before?: MessageID): Promise<Array<SavedMessage>> {
return this.contentRepo.listSavedMessages(userId, limit, before);
}
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
return this.contentRepo.createSavedMessage(userId, channelId, messageId);
}
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
return this.contentRepo.deleteSavedMessage(userId, messageId);
}
async deleteAllSavedMessages(userId: UserID): Promise<void> {
return this.contentRepo.deleteAllSavedMessages(userId);
}
async createGiftCode(data: GiftCodeRow): Promise<void> {
return this.contentRepo.createGiftCode(data);
}
async findGiftCode(code: string): Promise<GiftCode | null> {
return this.contentRepo.findGiftCode(code);
}
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
return this.contentRepo.findGiftCodeByPaymentIntent(paymentIntentId);
}
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
return this.contentRepo.findGiftCodesByCreator(userId);
}
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
return this.contentRepo.redeemGiftCode(code, userId);
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
return this.contentRepo.updateGiftCode(code, data);
}
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
return this.contentRepo.linkGiftCodeToCheckoutSession(code, checkoutSessionId);
}
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
return this.contentRepo.listPushSubscriptions(userId);
}
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
return this.contentRepo.createPushSubscription(data);
}
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
return this.contentRepo.deletePushSubscription(userId, subscriptionId);
}
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
return this.contentRepo.getBulkPushSubscriptions(userIds);
}
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
return this.contentRepo.deleteAllPushSubscriptions(userId);
}
async createPayment(data: {
checkout_session_id: string;
user_id: UserID;
price_id: string;
product_type: string;
status: string;
is_gift: boolean;
created_at: Date;
}): Promise<void> {
return this.contentRepo.createPayment(data);
}
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
return this.contentRepo.updatePayment(data);
}
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
return this.contentRepo.getPaymentByCheckoutSession(checkoutSessionId);
}
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
return this.contentRepo.getPaymentByPaymentIntent(paymentIntentId);
}
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
return this.contentRepo.getSubscriptionInfo(subscriptionId);
}
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
return this.contentRepo.listVisionarySlots();
}
async expandVisionarySlots(byCount: number): Promise<void> {
return this.contentRepo.expandVisionarySlots(byCount);
}
async shrinkVisionarySlots(toCount: number): Promise<void> {
return this.contentRepo.shrinkVisionarySlots(toCount);
}
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
return this.contentRepo.reserveVisionarySlot(slotIndex, userId);
}
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
return this.contentRepo.unreserveVisionarySlot(slotIndex, userId);
}
}

View File

@@ -0,0 +1,139 @@
/*
* 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 {
buildPatchFromData,
deleteOneOrMany,
executeVersionedUpdate,
fetchMany,
fetchOne,
} from '@fluxer/api/src/database/Cassandra';
import type {ExactRow} from '@fluxer/api/src/database/types/DatabaseRowTypes';
import type {UserGuildSettingsRow, UserSettingsRow} from '@fluxer/api/src/database/types/UserTypes';
import {USER_GUILD_SETTINGS_COLUMNS, USER_SETTINGS_COLUMNS} from '@fluxer/api/src/database/types/UserTypes';
import {Logger} from '@fluxer/api/src/Logger';
import {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import {UserSettings} from '@fluxer/api/src/models/UserSettings';
import {UserGuildSettings as UserGuildSettingsTable, UserSettings as UserSettingsTable} from '@fluxer/api/src/Tables';
import type {IUserSettingsRepository} from '@fluxer/api/src/user/repositories/IUserSettingsRepository';
const FETCH_USER_SETTINGS_CQL = UserSettingsTable.selectCql({
where: UserSettingsTable.where.eq('user_id'),
limit: 1,
});
const FETCH_USER_GUILD_SETTINGS_CQL = UserGuildSettingsTable.selectCql({
where: [UserGuildSettingsTable.where.eq('user_id'), UserGuildSettingsTable.where.eq('guild_id')],
limit: 1,
});
const FETCH_ALL_USER_GUILD_SETTINGS_CQL = UserGuildSettingsTable.selectCql({
where: UserGuildSettingsTable.where.eq('user_id'),
});
export class UserSettingsRepository implements IUserSettingsRepository {
async deleteAllUserGuildSettings(userId: UserID): Promise<void> {
await deleteOneOrMany(
UserGuildSettingsTable.deleteCql({
where: UserGuildSettingsTable.where.eq('user_id'),
}),
{user_id: userId},
);
}
async deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void> {
await deleteOneOrMany(
UserGuildSettingsTable.deleteByPk({
user_id: userId,
guild_id: guildId,
}),
);
}
async deleteUserSettings(userId: UserID): Promise<void> {
await deleteOneOrMany(UserSettingsTable.deleteByPk({user_id: userId}));
}
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
const settings = await fetchOne<UserGuildSettingsRow>(FETCH_USER_GUILD_SETTINGS_CQL, {
user_id: userId,
guild_id: guildId ? guildId : 0n,
});
return settings ? new UserGuildSettings(settings) : null;
}
async findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>> {
const rows = await fetchMany<UserGuildSettingsRow>(FETCH_ALL_USER_GUILD_SETTINGS_CQL, {
user_id: userId,
});
return rows.map((row) => new UserGuildSettings(row));
}
async findSettings(userId: UserID): Promise<UserSettings | null> {
const settings = await fetchOne<UserSettingsRow>(FETCH_USER_SETTINGS_CQL, {user_id: userId});
return settings ? new UserSettings(settings) : null;
}
async upsertGuildSettings(settings: ExactRow<UserGuildSettingsRow>): Promise<UserGuildSettings> {
const userId = settings.user_id;
const guildId = settings.guild_id;
const result = await executeVersionedUpdate<UserGuildSettingsRow, 'user_id' | 'guild_id'>(
() =>
this.findGuildSettings(userId, guildId)
.then((s) => s?.toRow() ?? null)
.catch((error) => {
Logger.error(
{userId: userId.toString(), guildId: guildId.toString(), error},
'Failed to fetch guild settings',
);
throw error;
}),
(current) => ({
pk: {user_id: userId, guild_id: guildId},
patch: buildPatchFromData(settings, current, USER_GUILD_SETTINGS_COLUMNS, ['user_id', 'guild_id']),
}),
UserGuildSettingsTable,
);
return new UserGuildSettings({...settings, version: result.finalVersion ?? 1});
}
async upsertSettings(settings: ExactRow<UserSettingsRow>): Promise<UserSettings> {
const userId = settings.user_id;
const result = await executeVersionedUpdate<UserSettingsRow, 'user_id'>(
() =>
this.findSettings(userId)
.then((s) => s?.toRow() ?? null)
.catch((error) => {
Logger.error({userId: userId.toString(), error}, 'Failed to fetch settings');
throw error;
}),
(current) => ({
pk: {user_id: userId},
patch: buildPatchFromData(settings, current, USER_SETTINGS_COLUMNS, ['user_id']),
}),
UserSettingsTable,
);
return new UserSettings({...settings, version: result.finalVersion ?? 1});
}
}

View File

@@ -0,0 +1,156 @@
/*
* 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 {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {VisionarySlotRow} from '@fluxer/api/src/database/types/PaymentTypes';
import {VisionarySlot} from '@fluxer/api/src/models/VisionarySlot';
import {VisionarySlots} from '@fluxer/api/src/Tables';
import {CannotShrinkReservedSlotsError} from '@fluxer/errors/src/domains/core/CannotShrinkReservedSlotsError';
const FETCH_ALL_VISIONARY_SLOTS_QUERY = VisionarySlots.selectCql();
const FETCH_VISIONARY_SLOT_QUERY = VisionarySlots.selectCql({
where: VisionarySlots.where.eq('slot_index'),
limit: 1,
});
export class VisionarySlotRepository {
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
const slots = await fetchMany<VisionarySlotRow>(FETCH_ALL_VISIONARY_SLOTS_QUERY, {});
return slots.map((slot) => new VisionarySlot(slot));
}
async getVisionarySlot(slotIndex: number): Promise<VisionarySlot | null> {
const slot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
slot_index: slotIndex,
});
return slot ? new VisionarySlot(slot) : null;
}
async expandVisionarySlots(byCount: number): Promise<void> {
const existingSlots = await this.listVisionarySlots();
const maxSlotIndex = existingSlots.length > 0 ? Math.max(...existingSlots.map((s) => s.slotIndex)) : 0;
const batch = new BatchBuilder();
for (let i = 1; i <= byCount; i++) {
const newSlotIndex = maxSlotIndex + i;
batch.addPrepared(
VisionarySlots.upsertAll({
slot_index: newSlotIndex,
user_id: null,
}),
);
}
await batch.execute();
}
async shrinkVisionarySlots(toCount: number): Promise<void> {
const existingSlots = await this.listVisionarySlots();
if (existingSlots.length <= toCount) return;
const sortedSlots = existingSlots.sort((a, b) => b.slotIndex - a.slotIndex);
const slotsToRemove = sortedSlots.slice(0, existingSlots.length - toCount);
const reservedSlots = slotsToRemove.filter((slot) => slot.userId !== null);
if (reservedSlots.length > 0) {
throw new CannotShrinkReservedSlotsError(reservedSlots.map((s) => s.slotIndex));
}
const batch = new BatchBuilder();
for (const slot of slotsToRemove) {
batch.addPrepared(VisionarySlots.deleteByPk({slot_index: slot.slotIndex}));
}
await batch.execute();
}
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
const existingSlot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
slot_index: slotIndex,
});
if (!existingSlot) {
await upsertOne(
VisionarySlots.upsertAll({
slot_index: slotIndex,
user_id: userId,
}),
);
} else {
await upsertOne(
VisionarySlots.upsertAll({
slot_index: slotIndex,
user_id: userId,
}),
);
}
}
async swapVisionarySlotReservations(
slotIndexA: number,
slotIndexB: number,
): Promise<{userIdA: UserID | null; userIdB: UserID | null}> {
const [slotA, slotB] = await Promise.all([
fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {slot_index: slotIndexA}),
fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {slot_index: slotIndexB}),
]);
const userIdA = slotA?.user_id ?? null;
const userIdB = slotB?.user_id ?? null;
const batch = new BatchBuilder();
batch.addPrepared(
VisionarySlots.upsertAll({
slot_index: slotIndexA,
user_id: userIdB,
}),
);
batch.addPrepared(
VisionarySlots.upsertAll({
slot_index: slotIndexB,
user_id: userIdA,
}),
);
await batch.execute();
return {userIdA, userIdB};
}
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
const existingSlot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
slot_index: slotIndex,
});
if (!existingSlot) {
return;
}
const sentinelUserId = createUserID(BigInt(-1));
if (userId !== sentinelUserId && existingSlot.user_id !== userId) {
return;
}
await upsertOne(
VisionarySlots.upsertAll({
slot_index: slotIndex,
user_id: null,
}),
);
}
}

View File

@@ -0,0 +1,150 @@
/*
* 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 {Db} from '@fluxer/api/src/database/Cassandra';
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import {User} from '@fluxer/api/src/models/User';
import {UserDataRepository} from '@fluxer/api/src/user/repositories/account/crud/UserDataRepository';
import {UserIndexRepository} from '@fluxer/api/src/user/repositories/account/crud/UserIndexRepository';
import {UserSearchRepository} from '@fluxer/api/src/user/repositories/account/crud/UserSearchRepository';
export class UserAccountRepository {
private dataRepo: UserDataRepository;
private indexRepo: UserIndexRepository;
private searchRepo: UserSearchRepository;
constructor() {
this.dataRepo = new UserDataRepository();
this.indexRepo = new UserIndexRepository();
this.searchRepo = new UserSearchRepository();
}
async create(data: UserRow): Promise<User> {
return this.upsert(data);
}
async findUnique(userId: UserID): Promise<User | null> {
return this.dataRepo.findUnique(userId);
}
async findUniqueAssert(userId: UserID): Promise<User> {
return this.dataRepo.findUniqueAssert(userId);
}
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
return this.dataRepo.listAllUsersPaginated(limit, lastUserId);
}
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
return this.dataRepo.listUsers(userIds);
}
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
const userId = data.user_id;
const result = await this.dataRepo.upsertUserRow(data, oldData);
if (result.finalVersion === null) {
throw new Error(`Failed to update user ${userId} after max retries due to concurrent updates`);
}
const updatedData = {...data, version: result.finalVersion};
const updatedUser = new User(updatedData);
await this.indexRepo.syncIndices(updatedData, oldData);
await this.searchRepo.indexUser(updatedUser);
return updatedUser;
}
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User> {
if (!oldData) {
const existingUser = await this.findUniqueAssert(userId);
oldData = existingUser.toRow();
}
const definedPatchData = Object.fromEntries(Object.entries(patchData).filter(([, v]) => v !== undefined));
const userPatch: Record<string, ReturnType<typeof Db.set> | ReturnType<typeof Db.clear>> = {};
for (const [key, value] of Object.entries(definedPatchData)) {
if (key === 'user_id') continue;
const userRowKey = key as keyof UserRow;
if (value === null) {
const oldVal = oldData?.[userRowKey];
if (oldVal !== null && oldVal !== undefined) {
userPatch[key] = Db.clear();
}
} else {
userPatch[key] = Db.set(value);
}
}
const result = await this.dataRepo.patchUser(userId, userPatch, oldData);
if (result.finalVersion === null) {
throw new Error(`Failed to update user ${userId} due to concurrent modification`);
}
const updatedData: UserRow = {
...oldData,
...definedPatchData,
user_id: userId,
version: result.finalVersion,
};
const updatedUser = new User(updatedData);
await this.indexRepo.syncIndices(updatedData, oldData);
await this.searchRepo.updateUser(updatedUser);
return updatedUser;
}
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
const user = await this.findUnique(userId);
if (!user) return;
await this.indexRepo.deleteIndices(
userId,
user.username,
user.discriminator,
user.email,
user.phone,
user.stripeSubscriptionId,
);
}
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
await this.dataRepo.updateLastActiveAt(params);
}
async getActivityTracking(
userId: UserID,
): Promise<{last_active_at: Date | null; last_active_ip: string | null} | null> {
return this.dataRepo.getActivityTracking(userId);
}
async updateSubscriptionStatus(
userId: UserID,
updates: {premiumWillCancel: boolean; computedPremiumUntil: Date | null},
): Promise<{finalVersion: number | null}> {
return this.dataRepo.updateSubscriptionStatus(userId, updates);
}
}

View File

@@ -0,0 +1,125 @@
/*
* 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 {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
import {deleteOneOrMany, fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {User} from '@fluxer/api/src/models/User';
import {UsersPendingDeletion} from '@fluxer/api/src/Tables';
const FETCH_USERS_PENDING_DELETION_BY_DATE_CQL = UsersPendingDeletion.selectCql({
columns: ['user_id', 'pending_deletion_at'],
where: [UsersPendingDeletion.where.eq('deletion_date'), UsersPendingDeletion.where.lte('pending_deletion_at', 'now')],
});
const FETCH_USERS_PENDING_DELETION_BY_DATE_ALL_CQL = UsersPendingDeletion.selectCql({
columns: ['user_id', 'deletion_reason_code'],
where: UsersPendingDeletion.where.eq('deletion_date'),
});
const FETCH_USER_PENDING_DELETION_CHECK_CQL = UsersPendingDeletion.selectCql({
columns: ['user_id'],
where: [UsersPendingDeletion.where.eq('deletion_date'), UsersPendingDeletion.where.eq('user_id')],
});
export class UserDeletionRepository {
constructor(private findUniqueUser: (userId: UserID) => Promise<User | null>) {}
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
const deletionDate = pendingDeletionAt.toISOString().split('T')[0];
await upsertOne(
UsersPendingDeletion.upsertAll({
deletion_date: deletionDate,
pending_deletion_at: pendingDeletionAt,
user_id: userId,
deletion_reason_code: deletionReasonCode,
}),
);
}
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
const userIds = new Set<bigint>();
const startDate = new Date(now);
startDate.setDate(startDate.getDate() - 30);
const currentDate = new Date(startDate);
while (currentDate <= now) {
const deletionDate = currentDate.toISOString().split('T')[0];
const rows = await fetchMany<{user_id: bigint; pending_deletion_at: Date}>(
FETCH_USERS_PENDING_DELETION_BY_DATE_CQL,
{
deletion_date: deletionDate,
now,
},
);
for (const row of rows) {
userIds.add(row.user_id);
}
currentDate.setDate(currentDate.getDate() + 1);
}
const users: Array<User> = [];
for (const userId of userIds) {
const user = await this.findUniqueUser(createUserID(userId));
if (user?.pendingDeletionAt && user.pendingDeletionAt <= now) {
users.push(user);
}
}
return users;
}
async findUsersPendingDeletionByDate(
deletionDate: string,
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
const rows = await fetchMany<{user_id: bigint; deletion_reason_code: number}>(
FETCH_USERS_PENDING_DELETION_BY_DATE_ALL_CQL,
{deletion_date: deletionDate},
);
return rows;
}
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
const rows = await fetchMany<{user_id: bigint}>(FETCH_USER_PENDING_DELETION_CHECK_CQL, {
deletion_date: deletionDate,
user_id: userId,
});
return rows.length > 0;
}
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
const deletionDate = pendingDeletionAt.toISOString().split('T')[0];
await deleteOneOrMany(
UsersPendingDeletion.deleteByPk({
deletion_date: deletionDate,
pending_deletion_at: pendingDeletionAt,
user_id: userId,
}),
);
}
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
return this.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
}
}

View File

@@ -0,0 +1,60 @@
/*
* 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 {BatchBuilder, fetchMany} from '@fluxer/api/src/database/Cassandra';
import type {GuildMemberByUserIdRow} from '@fluxer/api/src/database/types/GuildTypes';
import {GuildMembers, GuildMembersByUserId} from '@fluxer/api/src/Tables';
const FETCH_GUILD_MEMBERS_BY_USER_CQL = GuildMembersByUserId.selectCql({
where: GuildMembersByUserId.where.eq('user_id'),
});
export class UserGuildRepository {
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
const guilds = await fetchMany<GuildMemberByUserIdRow>(FETCH_GUILD_MEMBERS_BY_USER_CQL, {
user_id: userId,
});
return guilds.map((g) => g.guild_id);
}
async removeFromAllGuilds(userId: UserID): Promise<void> {
const guilds = await fetchMany<GuildMemberByUserIdRow>(FETCH_GUILD_MEMBERS_BY_USER_CQL, {
user_id: userId,
});
const batch = new BatchBuilder();
for (const guild of guilds) {
batch.addPrepared(
GuildMembers.deleteByPk({
guild_id: guild.guild_id,
user_id: userId,
}),
);
batch.addPrepared(
GuildMembersByUserId.deleteByPk({
user_id: userId,
guild_id: guild.guild_id,
}),
);
}
await batch.execute();
}
}

View File

@@ -0,0 +1,123 @@
/*
* 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 {fetchMany, fetchOne} from '@fluxer/api/src/database/Cassandra';
import type {
UserByEmailRow,
UserByPhoneRow,
UserByStripeCustomerIdRow,
UserByStripeSubscriptionIdRow,
UserByUsernameRow,
} from '@fluxer/api/src/database/types/UserTypes';
import type {User} from '@fluxer/api/src/models/User';
import {
UserByEmail,
UserByPhone,
UserByStripeCustomerId,
UserByStripeSubscriptionId,
UserByUsername,
} from '@fluxer/api/src/Tables';
const FETCH_DISCRIMINATORS_BY_USERNAME_QUERY = UserByUsername.select({
columns: ['discriminator', 'user_id'],
where: UserByUsername.where.eq('username'),
});
const FETCH_USER_ID_BY_EMAIL_QUERY = UserByEmail.select({
columns: ['user_id'],
where: UserByEmail.where.eq('email_lower'),
limit: 1,
});
const FETCH_USER_ID_BY_PHONE_QUERY = UserByPhone.select({
columns: ['user_id'],
where: UserByPhone.where.eq('phone'),
limit: 1,
});
const FETCH_USER_ID_BY_STRIPE_CUSTOMER_ID_QUERY = UserByStripeCustomerId.select({
columns: ['user_id'],
where: UserByStripeCustomerId.where.eq('stripe_customer_id'),
limit: 1,
});
const FETCH_USER_ID_BY_STRIPE_SUBSCRIPTION_ID_QUERY = UserByStripeSubscriptionId.select({
columns: ['user_id'],
where: UserByStripeSubscriptionId.where.eq('stripe_subscription_id'),
limit: 1,
});
const FETCH_USER_ID_BY_USERNAME_DISCRIMINATOR_QUERY = UserByUsername.select({
columns: ['user_id'],
where: [UserByUsername.where.eq('username'), UserByUsername.where.eq('discriminator')],
limit: 1,
});
export class UserLookupRepository {
constructor(private findUniqueUser: (userId: UserID) => Promise<User | null>) {}
async findByEmail(email: string): Promise<User | null> {
const emailLower = email.toLowerCase();
const result = await fetchOne<Pick<UserByEmailRow, 'user_id'>>(
FETCH_USER_ID_BY_EMAIL_QUERY.bind({email_lower: emailLower}),
);
if (!result) return null;
return await this.findUniqueUser(result.user_id);
}
async findByPhone(phone: string): Promise<User | null> {
const result = await fetchOne<Pick<UserByPhoneRow, 'user_id'>>(FETCH_USER_ID_BY_PHONE_QUERY.bind({phone}));
if (!result) return null;
return await this.findUniqueUser(result.user_id);
}
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
const result = await fetchOne<Pick<UserByStripeCustomerIdRow, 'user_id'>>(
FETCH_USER_ID_BY_STRIPE_CUSTOMER_ID_QUERY.bind({stripe_customer_id: stripeCustomerId}),
);
if (!result) return null;
return await this.findUniqueUser(result.user_id);
}
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
const result = await fetchOne<Pick<UserByStripeSubscriptionIdRow, 'user_id'>>(
FETCH_USER_ID_BY_STRIPE_SUBSCRIPTION_ID_QUERY.bind({stripe_subscription_id: stripeSubscriptionId}),
);
if (!result) return null;
return await this.findUniqueUser(result.user_id);
}
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
const usernameLower = username.toLowerCase();
const result = await fetchOne<Pick<UserByUsernameRow, 'user_id'>>(
FETCH_USER_ID_BY_USERNAME_DISCRIMINATOR_QUERY.bind({username: usernameLower, discriminator}),
);
if (!result) return null;
return await this.findUniqueUser(result.user_id);
}
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
const usernameLower = username.toLowerCase();
const result = await fetchMany<Pick<UserByUsernameRow, 'discriminator'>>(
FETCH_DISCRIMINATORS_BY_USERNAME_QUERY.bind({username: usernameLower}),
);
return new Set(result.map((r) => r.discriminator));
}
}

View File

@@ -0,0 +1,227 @@
/*
* 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 {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
import {
buildPatchFromData,
Db,
type DbOp,
executeVersionedUpdate,
fetchMany,
fetchOne,
upsertOne,
} from '@fluxer/api/src/database/Cassandra';
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import {EMPTY_USER_ROW, USER_COLUMNS} from '@fluxer/api/src/database/types/UserTypes';
import {User} from '@fluxer/api/src/models/User';
import {Users} from '@fluxer/api/src/Tables';
const FLUXER_BOT_USER_ID = 0n;
const DELETED_USER_ID = 1n;
const FETCH_USERS_BY_IDS_CQL = Users.selectCql({
where: Users.where.in('user_id', 'user_ids'),
});
const FETCH_USER_BY_ID_CQL = Users.selectCql({
where: Users.where.eq('user_id'),
limit: 1,
});
const UPDATE_LAST_ACTIVE_CQL = `UPDATE users SET last_active_at = :last_active_at, last_active_ip = :last_active_ip WHERE user_id = :user_id`;
const FETCH_ACTIVITY_TRACKING_CQL = Users.selectCql({
columns: ['last_active_at', 'last_active_ip'],
where: Users.where.eq('user_id'),
limit: 1,
});
function createFetchAllUsersFirstPageCql(limit: number) {
return Users.selectCql({limit});
}
const createFetchAllUsersPaginatedCql = (limit: number) =>
Users.selectCql({
where: Users.where.tokenGt('user_id', 'last_user_id'),
limit,
});
type UserPatch = Partial<{
[K in Exclude<keyof UserRow, 'user_id'> & string]: DbOp<UserRow[K]>;
}>;
export class UserDataRepository {
async findUnique(userId: UserID): Promise<User | null> {
if (userId === FLUXER_BOT_USER_ID) {
return new User({
...EMPTY_USER_ROW,
user_id: createUserID(FLUXER_BOT_USER_ID),
username: 'Fluxer',
discriminator: 0,
bot: true,
system: true,
});
}
if (userId === DELETED_USER_ID) {
return new User({
...EMPTY_USER_ROW,
user_id: createUserID(DELETED_USER_ID),
username: 'DeletedUser',
discriminator: 0,
bot: false,
system: true,
});
}
const userRow = await fetchOne<UserRow>(FETCH_USER_BY_ID_CQL, {user_id: userId});
if (!userRow) {
return null;
}
return new User(userRow);
}
async findUniqueAssert(userId: UserID): Promise<User> {
return (await this.findUnique(userId))!;
}
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
let users: Array<UserRow>;
if (lastUserId) {
const cql = createFetchAllUsersPaginatedCql(limit);
users = await fetchMany<UserRow>(cql, {last_user_id: lastUserId});
} else {
const cql = createFetchAllUsersFirstPageCql(limit);
users = await fetchMany<UserRow>(cql, {});
}
return users.map((user) => new User(user));
}
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
if (userIds.length === 0) return [];
const users = await fetchMany<UserRow>(FETCH_USERS_BY_IDS_CQL, {user_ids: userIds});
return users.map((user) => new User(user));
}
async upsertUserRow(data: UserRow, oldData?: UserRow | null): Promise<{finalVersion: number | null}> {
const userId = data.user_id;
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
async () => {
if (oldData !== undefined) {
return oldData;
}
const user = await this.findUnique(userId);
return user?.toRow() ?? null;
},
(current) => ({
pk: {user_id: userId},
patch: buildPatchFromData(data, current, USER_COLUMNS, ['user_id']),
}),
Users,
);
return {finalVersion: result.finalVersion};
}
async patchUser(userId: UserID, patch: UserPatch, oldData?: UserRow | null): Promise<{finalVersion: number | null}> {
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
async () => {
if (oldData !== undefined) {
return oldData;
}
const user = await this.findUnique(userId);
return user?.toRow() ?? null;
},
(_current) => ({
pk: {user_id: userId},
patch,
}),
Users,
);
return {finalVersion: result.finalVersion};
}
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
const {userId, lastActiveAt, lastActiveIp} = params;
const updateParams: {user_id: UserID; last_active_at: Date; last_active_ip?: string} = {
user_id: userId,
last_active_at: lastActiveAt,
};
if (lastActiveIp !== undefined) {
updateParams.last_active_ip = lastActiveIp;
}
await upsertOne(UPDATE_LAST_ACTIVE_CQL, updateParams);
}
async getActivityTracking(
userId: UserID,
): Promise<{last_active_at: Date | null; last_active_ip: string | null} | null> {
const result = await fetchOne<{last_active_at: Date | null; last_active_ip: string | null}>(
FETCH_ACTIVITY_TRACKING_CQL,
{user_id: userId},
);
return result;
}
async updateSubscriptionStatus(
userId: UserID,
updates: {
premiumWillCancel: boolean;
computedPremiumUntil: Date | null;
},
): Promise<{finalVersion: number | null}> {
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
async () => {
const user = await this.findUnique(userId);
return user?.toRow() ?? null;
},
(current) => {
const currentPremiumUntil = current?.premium_until ?? null;
const computedPremiumUntil = updates.computedPremiumUntil;
let nextPremiumUntil: Date | null = currentPremiumUntil;
if (computedPremiumUntil) {
if (!nextPremiumUntil || computedPremiumUntil > nextPremiumUntil) {
nextPremiumUntil = computedPremiumUntil;
}
}
const patch: UserPatch = {
premium_will_cancel: Db.set(updates.premiumWillCancel),
premium_until: nextPremiumUntil ? Db.set(nextPremiumUntil) : Db.clear(),
};
return {
pk: {user_id: userId},
patch,
};
},
Users,
);
return {finalVersion: result.finalVersion};
}
}

View File

@@ -0,0 +1,177 @@
/*
* 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 {BatchBuilder} from '@fluxer/api/src/database/Cassandra';
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import {
UserByEmail,
UserByPhone,
UserByStripeCustomerId,
UserByStripeSubscriptionId,
UserByUsername,
} from '@fluxer/api/src/Tables';
export class UserIndexRepository {
async syncIndices(data: UserRow, oldData?: UserRow | null): Promise<void> {
const batch = new BatchBuilder();
if (!!data.username && data.discriminator != null && data.discriminator !== undefined) {
batch.addPrepared(
UserByUsername.upsertAll({
username: data.username.toLowerCase(),
discriminator: data.discriminator,
user_id: data.user_id,
}),
);
}
if (oldData?.username && oldData.discriminator != null && oldData.discriminator !== undefined) {
if (
oldData.username.toLowerCase() !== data.username?.toLowerCase() ||
oldData.discriminator !== data.discriminator
) {
batch.addPrepared(
UserByUsername.deleteByPk({
username: oldData.username.toLowerCase(),
discriminator: oldData.discriminator,
user_id: oldData.user_id,
}),
);
}
}
if (data.email) {
batch.addPrepared(
UserByEmail.upsertAll({
email_lower: data.email.toLowerCase(),
user_id: data.user_id,
}),
);
}
if (oldData?.email && oldData.email.toLowerCase() !== data.email?.toLowerCase()) {
batch.addPrepared(
UserByEmail.deleteByPk({
email_lower: oldData.email.toLowerCase(),
user_id: oldData.user_id,
}),
);
}
if (data.phone) {
batch.addPrepared(
UserByPhone.upsertAll({
phone: data.phone,
user_id: data.user_id,
}),
);
}
if (oldData?.phone && oldData.phone !== data.phone) {
batch.addPrepared(
UserByPhone.deleteByPk({
phone: oldData.phone,
user_id: oldData.user_id,
}),
);
}
if (data.stripe_subscription_id) {
batch.addPrepared(
UserByStripeSubscriptionId.upsertAll({
stripe_subscription_id: data.stripe_subscription_id,
user_id: data.user_id,
}),
);
}
if (oldData?.stripe_subscription_id && oldData.stripe_subscription_id !== data.stripe_subscription_id) {
batch.addPrepared(
UserByStripeSubscriptionId.deleteByPk({
stripe_subscription_id: oldData.stripe_subscription_id,
user_id: oldData.user_id,
}),
);
}
if (data.stripe_customer_id) {
batch.addPrepared(
UserByStripeCustomerId.upsertAll({
stripe_customer_id: data.stripe_customer_id,
user_id: data.user_id,
}),
);
}
if (oldData?.stripe_customer_id && oldData.stripe_customer_id !== data.stripe_customer_id) {
batch.addPrepared(
UserByStripeCustomerId.deleteByPk({
stripe_customer_id: oldData.stripe_customer_id,
user_id: oldData.user_id,
}),
);
}
await batch.execute();
}
async deleteIndices(
userId: UserID,
username: string,
discriminator: number,
email?: string | null,
phone?: string | null,
stripeSubscriptionId?: string | null,
): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(
UserByUsername.deleteByPk({
username: username.toLowerCase(),
discriminator: discriminator,
user_id: userId,
}),
);
if (email) {
batch.addPrepared(
UserByEmail.deleteByPk({
email_lower: email.toLowerCase(),
user_id: userId,
}),
);
}
if (phone) {
batch.addPrepared(
UserByPhone.deleteByPk({
phone: phone,
user_id: userId,
}),
);
}
if (stripeSubscriptionId) {
batch.addPrepared(
UserByStripeSubscriptionId.deleteByPk({
stripe_subscription_id: stripeSubscriptionId,
user_id: userId,
}),
);
}
await batch.execute();
}
}

View File

@@ -0,0 +1,42 @@
/*
* 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 {Logger} from '@fluxer/api/src/Logger';
import type {User} from '@fluxer/api/src/models/User';
import {getUserSearchService} from '@fluxer/api/src/SearchFactory';
export class UserSearchRepository {
async indexUser(user: User): Promise<void> {
const userSearchService = getUserSearchService();
if (userSearchService && 'indexUser' in userSearchService) {
await userSearchService.indexUser(user).catch((error) => {
Logger.error({userId: user.id, error}, 'Failed to index user in search');
});
}
}
async updateUser(user: User): Promise<void> {
const userSearchService = getUserSearchService();
if (userSearchService && 'updateUser' in userSearchService) {
await userSearchService.updateUser(user).catch((error) => {
Logger.error({userId: user.id, error}, 'Failed to update user in search');
});
}
}
}

View File

@@ -0,0 +1,115 @@
/*
* 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 {BatchBuilder, Db, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {AuthSessionRow} from '@fluxer/api/src/database/types/AuthTypes';
import {AuthSession} from '@fluxer/api/src/models/AuthSession';
import {AuthSessions, AuthSessionsByUserId} from '@fluxer/api/src/Tables';
const FETCH_AUTH_SESSIONS_CQL = AuthSessions.selectCql({
where: AuthSessions.where.in('session_id_hash', 'session_id_hashes'),
});
const FETCH_AUTH_SESSION_BY_TOKEN_CQL = AuthSessions.selectCql({
where: AuthSessions.where.eq('session_id_hash'),
limit: 1,
});
const FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL = AuthSessionsByUserId.selectCql({
columns: ['session_id_hash'],
where: AuthSessionsByUserId.where.eq('user_id'),
});
export class AuthSessionRepository {
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
const batch = new BatchBuilder();
batch.addPrepared(AuthSessions.insert(sessionData));
batch.addPrepared(
AuthSessionsByUserId.insert({
user_id: sessionData.user_id,
session_id_hash: sessionData.session_id_hash,
}),
);
await batch.execute();
return new AuthSession(sessionData);
}
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
const session = await fetchOne<AuthSessionRow>(FETCH_AUTH_SESSION_BY_TOKEN_CQL, {session_id_hash: sessionIdHash});
return session ? new AuthSession(session) : null;
}
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
const sessionHashes = await fetchMany<{session_id_hash: Buffer}>(FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL, {
user_id: userId,
});
if (sessionHashes.length === 0) return [];
const sessions = await fetchMany<AuthSessionRow>(FETCH_AUTH_SESSIONS_CQL, {
session_id_hashes: sessionHashes.map((s) => s.session_id_hash),
});
return sessions.map((session) => new AuthSession(session));
}
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
await upsertOne(
AuthSessions.patchByPk(
{session_id_hash: sessionIdHash},
{
approx_last_used_at: Db.set(new Date()),
},
),
);
}
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
const batch = new BatchBuilder();
for (const sessionIdHash of sessionIdHashes) {
batch.addPrepared(AuthSessions.deleteByPk({session_id_hash: sessionIdHash}));
batch.addPrepared(AuthSessionsByUserId.deleteByPk({user_id: userId, session_id_hash: sessionIdHash}));
}
await batch.execute();
}
async deleteAllAuthSessions(userId: UserID): Promise<void> {
const sessions = await fetchMany<{session_id_hash: Buffer}>(FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL, {
user_id: userId,
});
const batch = new BatchBuilder();
for (const session of sessions) {
batch.addPrepared(
AuthSessions.deleteByPk({
session_id_hash: session.session_id_hash,
}),
);
batch.addPrepared(
AuthSessionsByUserId.deleteByPk({
user_id: userId,
session_id_hash: session.session_id_hash,
}),
);
}
if (batch) {
await batch.execute();
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* 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 {deleteOneOrMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {EmailChangeTicketRow, EmailChangeTokenRow} from '@fluxer/api/src/database/types/AuthTypes';
import {EmailChangeTickets, EmailChangeTokens} from '@fluxer/api/src/Tables';
const FETCH_TICKET_CQL = EmailChangeTickets.selectCql({
where: EmailChangeTickets.where.eq('ticket'),
limit: 1,
});
const FETCH_TOKEN_CQL = EmailChangeTokens.selectCql({
where: EmailChangeTokens.where.eq('token_'),
limit: 1,
});
export class EmailChangeRepository {
async createTicket(row: EmailChangeTicketRow): Promise<void> {
await upsertOne(EmailChangeTickets.insert(row));
}
async updateTicket(row: EmailChangeTicketRow): Promise<void> {
await upsertOne(EmailChangeTickets.upsertAll(row));
}
async findTicket(ticket: string): Promise<EmailChangeTicketRow | null> {
return await fetchOne<EmailChangeTicketRow>(FETCH_TICKET_CQL, {ticket});
}
async deleteTicket(ticket: string): Promise<void> {
await deleteOneOrMany(EmailChangeTickets.deleteByPk({ticket}));
}
async createToken(row: EmailChangeTokenRow): Promise<void> {
await upsertOne(EmailChangeTokens.insert(row));
}
async findToken(token: string): Promise<EmailChangeTokenRow | null> {
return await fetchOne<EmailChangeTokenRow>(FETCH_TOKEN_CQL, {token_: token});
}
async deleteToken(token: string): Promise<void> {
await deleteOneOrMany(EmailChangeTokens.deleteByPk({token_: token}));
}
}

View File

@@ -0,0 +1,117 @@
/*
* 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 {createIpAuthorizationToken, type UserID} from '@fluxer/api/src/BrandedTypes';
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {AuthorizedIpRow, IpAuthorizationTokenRow} from '@fluxer/api/src/database/types/AuthTypes';
import {AuthorizedIps, IpAuthorizationTokens, Users} from '@fluxer/api/src/Tables';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
const AUTHORIZE_IP_BY_TOKEN_CQL = IpAuthorizationTokens.selectCql({
where: IpAuthorizationTokens.where.eq('token_'),
limit: 1,
});
const CHECK_IP_AUTHORIZED_CQL = AuthorizedIps.selectCql({
where: [AuthorizedIps.where.eq('user_id'), AuthorizedIps.where.eq('ip')],
limit: 1,
});
const GET_AUTHORIZED_IPS_CQL = AuthorizedIps.selectCql({
where: AuthorizedIps.where.eq('user_id'),
});
export class IpAuthorizationRepository {
constructor(private userAccountRepository: IUserAccountRepository) {}
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
const result = await fetchOne<AuthorizedIpRow>(CHECK_IP_AUTHORIZED_CQL, {
user_id: userId,
ip,
});
return !!result;
}
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
await upsertOne(AuthorizedIps.insert({user_id: userId, ip}));
}
async createIpAuthorizationToken(userId: UserID, token: string, email: string): Promise<void> {
await upsertOne(
IpAuthorizationTokens.insert({
token_: createIpAuthorizationToken(token),
user_id: userId,
email,
}),
);
}
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
const result = await fetchOne<IpAuthorizationTokenRow>(AUTHORIZE_IP_BY_TOKEN_CQL, {token_: token});
if (!result) {
return null;
}
await deleteOneOrMany(
IpAuthorizationTokens.deleteByPk({
token_: createIpAuthorizationToken(token),
user_id: result.user_id,
}),
);
const user = await this.userAccountRepository.findUnique(result.user_id);
if (!user || user.flags & UserFlags.DELETED) {
return null;
}
return {userId: result.user_id, email: result.email};
}
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
const now = new Date();
await upsertOne(
Users.patchByPk(
{user_id: userId},
{
last_active_at: Db.set(now),
last_active_ip: Db.set(clientIp),
},
),
);
}
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
const ips = await fetchMany<AuthorizedIpRow>(GET_AUTHORIZED_IPS_CQL, {user_id: userId});
return ips.map((row) => ({ip: row.ip}));
}
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
const ips = await fetchMany<AuthorizedIpRow>(GET_AUTHORIZED_IPS_CQL, {user_id: userId});
for (const row of ips) {
await deleteOneOrMany(
AuthorizedIps.deleteByPk({
user_id: userId,
ip: row.ip,
}),
);
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* 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 {createMfaBackupCode, type UserID} from '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {MfaBackupCodeRow} from '@fluxer/api/src/database/types/AuthTypes';
import {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
import {MfaBackupCodes} from '@fluxer/api/src/Tables';
const FETCH_MFA_BACKUP_CODES_CQL = MfaBackupCodes.selectCql({
where: MfaBackupCodes.where.eq('user_id'),
});
export class MfaBackupCodeRepository {
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
const codes = await fetchMany<MfaBackupCodeRow>(FETCH_MFA_BACKUP_CODES_CQL, {user_id: userId});
return codes.map((code) => new MfaBackupCode(code));
}
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
const batch = new BatchBuilder();
const backupCodes: Array<MfaBackupCode> = [];
for (const code of codes) {
const codeRow: MfaBackupCodeRow = {user_id: userId, code: createMfaBackupCode(code), consumed: false};
batch.addPrepared(MfaBackupCodes.insert(codeRow));
backupCodes.push(new MfaBackupCode(codeRow));
}
await batch.execute();
return backupCodes;
}
async clearMfaBackupCodes(userId: UserID): Promise<void> {
const codes = await this.listMfaBackupCodes(userId);
if (codes.length === 0) return;
const batch = new BatchBuilder();
for (const code of codes) {
batch.addPrepared(MfaBackupCodes.deleteByPk({user_id: userId, code: createMfaBackupCode(code.code)}));
}
await batch.execute();
}
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
await upsertOne(
MfaBackupCodes.patchByPk(
{user_id: userId, code: createMfaBackupCode(code)},
{
consumed: Db.set(true),
},
),
);
}
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
await deleteOneOrMany(MfaBackupCodes.deleteCql({where: MfaBackupCodes.where.eq('user_id')}), {user_id: userId});
}
}

View File

@@ -0,0 +1,45 @@
/*
* 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 {deleteOneOrMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {PasswordChangeTicketRow} from '@fluxer/api/src/database/types/AuthTypes';
import {PasswordChangeTickets} from '@fluxer/api/src/Tables';
const FETCH_TICKET_CQL = PasswordChangeTickets.selectCql({
where: PasswordChangeTickets.where.eq('ticket'),
limit: 1,
});
export class PasswordChangeRepository {
async createTicket(row: PasswordChangeTicketRow): Promise<void> {
await upsertOne(PasswordChangeTickets.insert(row));
}
async updateTicket(row: PasswordChangeTicketRow): Promise<void> {
await upsertOne(PasswordChangeTickets.upsertAll(row));
}
async findTicket(ticket: string): Promise<PasswordChangeTicketRow | null> {
return await fetchOne<PasswordChangeTicketRow>(FETCH_TICKET_CQL, {ticket});
}
async deleteTicket(ticket: string): Promise<void> {
await deleteOneOrMany(PasswordChangeTickets.deleteByPk({ticket}));
}
}

View File

@@ -0,0 +1,143 @@
/*
* 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 {PhoneVerificationToken, UserID} from '@fluxer/api/src/BrandedTypes';
import {
createEmailRevertToken,
createEmailVerificationToken,
createPasswordResetToken,
} from '@fluxer/api/src/BrandedTypes';
import {deleteOneOrMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {
EmailRevertTokenRow,
EmailVerificationTokenRow,
PasswordResetTokenRow,
PhoneTokenRow,
} from '@fluxer/api/src/database/types/AuthTypes';
import {EmailRevertToken} from '@fluxer/api/src/models/EmailRevertToken';
import {EmailVerificationToken} from '@fluxer/api/src/models/EmailVerificationToken';
import {PasswordResetToken} from '@fluxer/api/src/models/PasswordResetToken';
import {EmailRevertTokens, EmailVerificationTokens, PasswordResetTokens, PhoneTokens} from '@fluxer/api/src/Tables';
import {seconds} from 'itty-time';
const FETCH_EMAIL_VERIFICATION_TOKEN_CQL = EmailVerificationTokens.selectCql({
where: EmailVerificationTokens.where.eq('token_'),
limit: 1,
});
const FETCH_PASSWORD_RESET_TOKEN_CQL = PasswordResetTokens.selectCql({
where: PasswordResetTokens.where.eq('token_'),
limit: 1,
});
const FETCH_EMAIL_REVERT_TOKEN_CQL = EmailRevertTokens.selectCql({
where: EmailRevertTokens.where.eq('token_'),
limit: 1,
});
const FETCH_PHONE_TOKEN_CQL = PhoneTokens.selectCql({
where: PhoneTokens.where.eq('token_'),
limit: 1,
});
export class TokenRepository {
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
const tokenRow = await fetchOne<EmailVerificationTokenRow>(FETCH_EMAIL_VERIFICATION_TOKEN_CQL, {token_: token});
return tokenRow ? new EmailVerificationToken(tokenRow) : null;
}
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
await upsertOne(EmailVerificationTokens.insert(tokenData));
return new EmailVerificationToken(tokenData);
}
async deleteEmailVerificationToken(token: string): Promise<void> {
await deleteOneOrMany(
EmailVerificationTokens.deleteCql({
where: EmailVerificationTokens.where.eq('token_'),
}),
{token_: createEmailVerificationToken(token)},
);
}
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
const tokenRow = await fetchOne<PasswordResetTokenRow>(FETCH_PASSWORD_RESET_TOKEN_CQL, {token_: token});
return tokenRow ? new PasswordResetToken(tokenRow) : null;
}
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
await upsertOne(PasswordResetTokens.insert(tokenData));
return new PasswordResetToken(tokenData);
}
async deletePasswordResetToken(token: string): Promise<void> {
await deleteOneOrMany(
PasswordResetTokens.deleteCql({
where: PasswordResetTokens.where.eq('token_'),
}),
{token_: createPasswordResetToken(token)},
);
}
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
const tokenRow = await fetchOne<EmailRevertTokenRow>(FETCH_EMAIL_REVERT_TOKEN_CQL, {token_: token});
return tokenRow ? new EmailRevertToken(tokenRow) : null;
}
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
await upsertOne(EmailRevertTokens.insert(tokenData));
return new EmailRevertToken(tokenData);
}
async deleteEmailRevertToken(token: string): Promise<void> {
await deleteOneOrMany(
EmailRevertTokens.deleteCql({
where: EmailRevertTokens.where.eq('token_'),
}),
{token_: createEmailRevertToken(token)},
);
}
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
const TTL = seconds('15 minutes');
await upsertOne(
PhoneTokens.insertWithTtl(
{
token_: token,
phone,
user_id: userId,
},
TTL,
),
);
}
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
return await fetchOne<PhoneTokenRow>(FETCH_PHONE_TOKEN_CQL, {token_: token});
}
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
await deleteOneOrMany(
PhoneTokens.deleteCql({
where: PhoneTokens.where.eq('token_'),
}),
{token_: token},
);
}
}

View File

@@ -0,0 +1,171 @@
/*
* 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 {BatchBuilder, Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {WebAuthnCredentialRow} from '@fluxer/api/src/database/types/AuthTypes';
import {WebAuthnCredential} from '@fluxer/api/src/models/WebAuthnCredential';
import {WebAuthnCredentialLookup, WebAuthnCredentials} from '@fluxer/api/src/Tables';
const FETCH_USER_ID_BY_CREDENTIAL_ID_CQL = WebAuthnCredentialLookup.selectCql({
where: WebAuthnCredentialLookup.where.eq('credential_id'),
limit: 1,
});
const FETCH_WEBAUTHN_CREDENTIALS_CQL = WebAuthnCredentials.selectCql({
where: WebAuthnCredentials.where.eq('user_id'),
});
const FETCH_WEBAUTHN_CREDENTIAL_CQL = WebAuthnCredentials.selectCql({
where: [WebAuthnCredentials.where.eq('user_id'), WebAuthnCredentials.where.eq('credential_id')],
limit: 1,
});
const FETCH_WEBAUTHN_CREDENTIALS_FOR_USER_CQL = WebAuthnCredentials.selectCql({
columns: ['credential_id'],
where: WebAuthnCredentials.where.eq('user_id'),
});
export class WebAuthnRepository {
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
const credentials = await fetchMany<WebAuthnCredentialRow>(FETCH_WEBAUTHN_CREDENTIALS_CQL, {user_id: userId});
return credentials.map((cred) => new WebAuthnCredential(cred));
}
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
const cred = await fetchOne<WebAuthnCredentialRow>(FETCH_WEBAUTHN_CREDENTIAL_CQL, {
user_id: userId,
credential_id: credentialId,
});
if (!cred) {
return null;
}
return new WebAuthnCredential(cred);
}
async createWebAuthnCredential(
userId: UserID,
credentialId: string,
publicKey: Buffer,
counter: bigint,
transports: Set<string> | null,
name: string,
): Promise<void> {
const credentialData = {
user_id: userId,
credential_id: credentialId,
public_key: publicKey,
counter: counter,
transports: transports,
name: name,
created_at: new Date(),
last_used_at: null,
version: 1 as const,
};
await upsertOne(WebAuthnCredentials.insert(credentialData));
await upsertOne(
WebAuthnCredentialLookup.insert({
credential_id: credentialId,
user_id: userId,
}),
);
}
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
await upsertOne(
WebAuthnCredentials.patchByPk(
{user_id: userId, credential_id: credentialId},
{
counter: Db.set(counter),
},
),
);
}
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
await upsertOne(
WebAuthnCredentials.patchByPk(
{user_id: userId, credential_id: credentialId},
{
last_used_at: Db.set(new Date()),
},
),
);
}
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
await upsertOne(
WebAuthnCredentials.patchByPk(
{user_id: userId, credential_id: credentialId},
{
name: Db.set(name),
},
),
);
}
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
await deleteOneOrMany(
WebAuthnCredentials.deleteByPk({
user_id: userId,
credential_id: credentialId,
}),
);
await deleteOneOrMany(
WebAuthnCredentialLookup.deleteByPk({
credential_id: credentialId,
}),
);
}
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
const row = await fetchOne<{credential_id: string; user_id: UserID}>(FETCH_USER_ID_BY_CREDENTIAL_ID_CQL, {
credential_id: credentialId,
});
return row?.user_id ?? null;
}
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
const credentials = await fetchMany<{credential_id: string}>(FETCH_WEBAUTHN_CREDENTIALS_FOR_USER_CQL, {
user_id: userId,
});
const batch = new BatchBuilder();
for (const cred of credentials) {
batch.addPrepared(
WebAuthnCredentials.deleteByPk({
user_id: userId,
credential_id: cred.credential_id,
}),
);
batch.addPrepared(
WebAuthnCredentialLookup.deleteByPk({
credential_id: cred.credential_id,
}),
);
}
await batch.execute();
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {User} from '@fluxer/api/src/models/User';
import {invalidateUserCache, updateUserCache} from '@fluxer/api/src/user/UserCacheHelpers';
import {mapUserToPrivateResponse} from '@fluxer/api/src/user/UserMappers';
export interface BaseUserUpdatePropagatorDeps {
userCacheService: UserCacheService;
gatewayService: IGatewayService;
}
export class BaseUserUpdatePropagator {
constructor(protected readonly baseDeps: BaseUserUpdatePropagatorDeps) {}
async dispatchUserUpdate(user: User): Promise<void> {
await this.baseDeps.gatewayService.dispatchPresence({
userId: user.id,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(user),
});
}
async invalidateUserCache(userId: UserID): Promise<void> {
await invalidateUserCache({
userId,
userCacheService: this.baseDeps.userCacheService,
});
}
async updateUserCache(user: User): Promise<void> {
await updateUserCache({
user,
userCacheService: this.baseDeps.userCacheService,
});
}
}

View File

@@ -0,0 +1,112 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createEmojiID, type EmojiID, type UserID} from '@fluxer/api/src/BrandedTypes';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {PackService} from '@fluxer/api/src/pack/PackService';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {CustomStatusPayload} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {z} from 'zod';
export interface ValidatedCustomStatus {
text: string | null;
expiresAt: Date | null;
emojiId: EmojiID | null;
emojiName: string | null;
emojiAnimated: boolean;
}
export class CustomStatusValidator {
constructor(
private readonly userAccountRepository: IUserAccountRepository,
private readonly guildRepository: IGuildRepositoryAggregate,
private readonly packService: PackService,
private readonly limitConfigService: LimitConfigService,
) {}
async validate(userId: UserID, payload: z.infer<typeof CustomStatusPayload>): Promise<ValidatedCustomStatus> {
const text = payload.text ?? null;
const expiresAt = payload.expires_at ?? null;
let emojiId: EmojiID | null = null;
let emojiName: string | null = null;
let emojiAnimated = false;
if (payload.emoji_id != null) {
emojiId = createEmojiID(payload.emoji_id);
const emoji = await this.guildRepository.getEmojiById(emojiId);
if (!emoji) {
throw InputValidationError.fromCode('custom_status.emoji_id', ValidationErrorCodes.CUSTOM_EMOJI_NOT_FOUND);
}
const user = await this.userAccountRepository.findUnique(userId);
const ctx = createLimitMatchContext({user});
const hasGlobalExpressions = resolveLimitSafe(
this.limitConfigService.getConfigSnapshot(),
ctx,
'feature_global_expressions',
0,
);
if (hasGlobalExpressions === 0) {
throw InputValidationError.fromCode(
'custom_status.emoji_id',
ValidationErrorCodes.PREMIUM_REQUIRED_FOR_CUSTOM_EMOJI,
);
}
const guildMember = await this.guildRepository.getMember(emoji.guildId, userId);
let hasAccess = guildMember !== null;
if (!hasAccess) {
const resolver = await this.packService.createPackExpressionAccessResolver({
userId,
type: 'emoji',
});
const resolution = await resolver.resolve(emoji.guildId);
hasAccess = resolution === 'accessible';
}
if (!hasAccess) {
throw InputValidationError.fromCode(
'custom_status.emoji_id',
ValidationErrorCodes.EMOJI_REQUIRES_GUILD_OR_PACK_ACCESS,
);
}
emojiName = emoji.name;
emojiAnimated = emoji.isAnimated;
} else if (payload.emoji_name != null) {
emojiName = payload.emoji_name;
}
return {
text,
expiresAt,
emojiId,
emojiName,
emojiAnimated,
};
}
}

View File

@@ -0,0 +1,411 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import {EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS} from '@fluxer/api/src/auth/services/AuthEmailService';
import type {User} from '@fluxer/api/src/models/User';
import type {EmailChangeRepository} from '@fluxer/api/src/user/repositories/auth/EmailChangeRepository';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import {ms} from 'itty-time';
export interface StartEmailChangeResult {
ticket: string;
require_original: boolean;
original_email?: string | null;
original_proof?: string | null;
original_code_expires_at?: string | null;
resend_available_at?: string | null;
}
export interface VerifyOriginalResult {
original_proof: string;
}
export interface RequestNewEmailResult {
ticket: string;
new_email: string;
new_code_expires_at: string;
resend_available_at: string | null;
}
export class EmailChangeService {
private readonly ORIGINAL_CODE_TTL_MS = ms('10 minutes');
private readonly NEW_CODE_TTL_MS = ms('10 minutes');
private readonly TOKEN_TTL_MS = ms('30 minutes');
private readonly RESEND_COOLDOWN_MS = ms('30 seconds');
constructor(
private readonly repo: EmailChangeRepository,
private readonly emailService: IEmailService,
private readonly userAccountRepository: IUserAccountRepository,
private readonly rateLimitService: IRateLimitService,
) {}
async start(user: User): Promise<StartEmailChangeResult> {
const isUnclaimed = user.isUnclaimedAccount();
const hasEmail = !!user.email;
if (!hasEmail && !isUnclaimed) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.MUST_HAVE_EMAIL_TO_CHANGE_IT);
}
const ticket = this.generateTicket();
const requireOriginal = !!user.emailVerified && hasEmail;
const now = new Date();
let originalCode: string | null = null;
let originalCodeExpiresAt: Date | null = null;
let originalCodeSentAt: Date | null = null;
if (requireOriginal) {
await this.ensureRateLimit(`email_change:orig:${user.id}`, 3, ms('15 minutes'));
originalCode = this.generateCode();
originalCodeExpiresAt = new Date(now.getTime() + this.ORIGINAL_CODE_TTL_MS);
originalCodeSentAt = now;
await this.emailService.sendEmailChangeOriginal(user.email!, user.username, originalCode, user.locale);
}
const originalProof = requireOriginal ? null : this.generateProof();
await this.repo.createTicket({
ticket,
user_id: user.id,
require_original: requireOriginal,
original_email: user.email,
original_verified: !requireOriginal,
original_proof: originalProof,
original_code: originalCode,
original_code_sent_at: originalCodeSentAt,
original_code_expires_at: originalCodeExpiresAt,
new_email: null,
new_code: null,
new_code_sent_at: null,
new_code_expires_at: null,
status: requireOriginal ? 'pending_original' : 'pending_new',
created_at: now,
updated_at: now,
});
return {
ticket,
require_original: requireOriginal,
original_email: user.email ?? null,
original_proof: originalProof,
original_code_expires_at: originalCodeExpiresAt ? originalCodeExpiresAt.toISOString() : null,
resend_available_at: requireOriginal ? new Date(now.getTime() + this.RESEND_COOLDOWN_MS).toISOString() : null,
};
}
async resendOriginal(user: User, ticket: string): Promise<void> {
const row = await this.getTicketForUser(ticket, user.id);
if (!row.require_original || row.original_verified) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_ALREADY_VERIFIED);
}
if (!row.original_email) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.NO_ORIGINAL_EMAIL_ON_RECORD);
}
this.assertCooldown(row.original_code_sent_at);
await this.ensureRateLimit(`email_change:orig:${user.id}`, 3, ms('15 minutes'));
const now = new Date();
const originalCode = this.generateCode();
const originalCodeExpiresAt = new Date(now.getTime() + this.ORIGINAL_CODE_TTL_MS);
await this.emailService.sendEmailChangeOriginal(row.original_email, user.username, originalCode, user.locale);
row.original_code = originalCode;
row.original_code_sent_at = now;
row.original_code_expires_at = originalCodeExpiresAt;
row.updated_at = now;
await this.repo.updateTicket(row);
}
async verifyOriginal(user: User, ticket: string, code: string): Promise<VerifyOriginalResult> {
const row = await this.getTicketForUser(ticket, user.id);
if (!row.require_original) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_VERIFICATION_NOT_REQUIRED);
}
if (row.original_verified && row.original_proof) {
return {original_proof: row.original_proof};
}
if (!row.original_code || !row.original_code_expires_at) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_NOT_ISSUED);
}
if (row.original_code_expires_at.getTime() < Date.now()) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_EXPIRED);
}
if (row.original_code !== code.trim()) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_VERIFICATION_CODE);
}
const now = new Date();
const originalProof = this.generateProof();
row.original_verified = true;
row.original_proof = originalProof;
row.status = 'pending_new';
row.updated_at = now;
await this.repo.updateTicket(row);
return {original_proof: originalProof};
}
async requestNewEmail(
user: User,
ticket: string,
newEmail: string,
originalProof: string,
): Promise<RequestNewEmailResult> {
const row = await this.getTicketForUser(ticket, user.id);
if (!row.original_verified || !row.original_proof) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST);
}
if (row.original_proof !== originalProof) {
throw InputValidationError.fromCode('original_proof', ValidationErrorCodes.INVALID_PROOF_TOKEN);
}
const trimmedEmail = newEmail.trim();
if (!trimmedEmail) {
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.EMAIL_IS_REQUIRED);
}
if (row.original_email && trimmedEmail.toLowerCase() === row.original_email.toLowerCase()) {
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.NEW_EMAIL_MUST_BE_DIFFERENT);
}
const existing = await this.userAccountRepository.findByEmail(trimmedEmail.toLowerCase());
if (existing && existing.id !== user.id) {
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE);
}
this.assertCooldown(row.new_code_sent_at);
await this.ensureRateLimit(`email_change:new:${user.id}`, 5, ms('15 minutes'));
const now = new Date();
const newCode = this.generateCode();
const newCodeExpiresAt = new Date(now.getTime() + this.NEW_CODE_TTL_MS);
await this.emailService.sendEmailChangeNew(trimmedEmail, user.username, newCode, user.locale);
row.new_email = trimmedEmail;
row.new_code = newCode;
row.new_code_sent_at = now;
row.new_code_expires_at = newCodeExpiresAt;
row.status = 'pending_new';
row.updated_at = now;
await this.repo.updateTicket(row);
return {
ticket,
new_email: trimmedEmail,
new_code_expires_at: newCodeExpiresAt.toISOString(),
resend_available_at: new Date(now.getTime() + this.RESEND_COOLDOWN_MS).toISOString(),
};
}
async resendNew(user: User, ticket: string): Promise<void> {
const row = await this.getTicketForUser(ticket, user.id);
if (!row.new_email) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.NO_NEW_EMAIL_REQUESTED);
}
this.assertCooldown(row.new_code_sent_at);
await this.ensureRateLimit(`email_change:new:${user.id}`, 5, ms('15 minutes'));
const now = new Date();
const newCode = this.generateCode();
const newCodeExpiresAt = new Date(now.getTime() + this.NEW_CODE_TTL_MS);
await this.emailService.sendEmailChangeNew(row.new_email, user.username, newCode, user.locale);
row.new_code = newCode;
row.new_code_sent_at = now;
row.new_code_expires_at = newCodeExpiresAt;
row.updated_at = now;
await this.repo.updateTicket(row);
}
async verifyNew(user: User, ticket: string, code: string, originalProof: string): Promise<string> {
const row = await this.getTicketForUser(ticket, user.id);
if (!row.original_verified || !row.original_proof) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST);
}
if (row.original_proof !== originalProof) {
throw InputValidationError.fromCode('original_proof', ValidationErrorCodes.INVALID_PROOF_TOKEN);
}
if (!row.new_email || !row.new_code || !row.new_code_expires_at) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_NOT_ISSUED);
}
if (row.new_code_expires_at.getTime() < Date.now()) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_EXPIRED);
}
if (row.new_code !== code.trim()) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_VERIFICATION_CODE);
}
const now = new Date();
const token = this.generateToken();
const expiresAt = new Date(now.getTime() + this.TOKEN_TTL_MS);
await this.repo.createToken({
token_: token,
user_id: user.id,
new_email: row.new_email,
expires_at: expiresAt,
created_at: now,
});
row.status = 'completed';
row.updated_at = now;
await this.repo.updateTicket(row);
return token;
}
async consumeToken(userId: bigint, token: string): Promise<string> {
const row = await this.repo.findToken(token);
if (!row || row.user_id !== userId) {
throw InputValidationError.fromCode('email_token', ValidationErrorCodes.INVALID_EMAIL_TOKEN);
}
if (row.expires_at.getTime() < Date.now()) {
await this.repo.deleteToken(token);
throw InputValidationError.fromCode('email_token', ValidationErrorCodes.EMAIL_TOKEN_EXPIRED);
}
await this.repo.deleteToken(token);
return row.new_email;
}
async requestBouncedNewEmail(user: User, newEmail: string): Promise<RequestNewEmailResult> {
this.ensureBouncedEmailRecoveryAllowed(user);
const startResult = await this.start(user);
if (startResult.require_original || !startResult.original_proof) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST);
}
return await this.requestNewEmail(user, startResult.ticket, newEmail, startResult.original_proof);
}
async resendBouncedNew(user: User, ticket: string): Promise<void> {
this.ensureBouncedEmailRecoveryAllowed(user);
await this.resendNew(user, ticket);
}
async verifyBouncedNew(user: User, ticket: string, code: string): Promise<User> {
this.ensureBouncedEmailRecoveryAllowed(user);
const row = await this.getTicketForUser(ticket, user.id);
if (row.require_original || !row.original_proof) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST);
}
const emailToken = await this.verifyNew(user, ticket, code, row.original_proof);
const updatedEmail = await this.consumeToken(user.id, emailToken);
const updates: {
email: string;
email_verified: boolean;
email_bounced: boolean;
suspicious_activity_flags?: number;
} = {
email: updatedEmail,
email_verified: true,
email_bounced: false,
};
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
const newFlags = user.suspiciousActivityFlags & ~EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS;
if (newFlags !== user.suspiciousActivityFlags) {
updates.suspicious_activity_flags = newFlags;
}
}
return await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
}
private async getTicketForUser(ticket: string, userId: bigint) {
const row = await this.repo.findTicket(ticket);
if (!row || row.user_id !== userId) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_TICKET);
}
if (row.status === 'completed') {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.TICKET_ALREADY_COMPLETED);
}
return row;
}
private ensureBouncedEmailRecoveryAllowed(user: User): void {
if (!user.emailBounced) {
throw new AccessDeniedError();
}
if (!user.email) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.MUST_HAVE_EMAIL_TO_CHANGE_IT);
}
}
private generateCode(): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let raw = '';
while (raw.length < 8) {
const byte = crypto.randomBytes(1)[0];
const idx = byte % alphabet.length;
raw += alphabet[idx];
}
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}`;
}
private generateTicket(): string {
return crypto.randomUUID();
}
private generateToken(): string {
return crypto.randomUUID();
}
private generateProof(): string {
return crypto.randomUUID();
}
private assertCooldown(sentAt: Date | null | undefined) {
if (!sentAt) return;
const nextAllowed = sentAt.getTime() + this.RESEND_COOLDOWN_MS;
if (nextAllowed > Date.now()) {
const retryAfter = Math.ceil((nextAllowed - Date.now()) / 1000);
throw new RateLimitError({
message: 'Please wait before resending.',
retryAfter,
limit: 1,
resetTime: new Date(nextAllowed),
});
}
}
private async ensureRateLimit(identifier: string, maxAttempts: number, windowMs: number) {
const result = await this.rateLimitService.checkLimit({identifier, maxAttempts, windowMs});
if (!result.allowed) {
throw new RateLimitError({
message: 'Too many attempts. Please try again later.',
retryAfter: result.retryAfter || 0,
limit: result.limit,
resetTime: result.resetTime,
});
}
}
}

View File

@@ -0,0 +1,228 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import type {User} from '@fluxer/api/src/models/User';
import type {PasswordChangeRepository} from '@fluxer/api/src/user/repositories/auth/PasswordChangeRepository';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import {ms} from 'itty-time';
export interface IAuthServiceForPasswordChange {
hashPassword(password: string): Promise<string>;
isPasswordPwned(password: string): Promise<boolean>;
}
export interface StartPasswordChangeResult {
ticket: string;
code_expires_at: string;
resend_available_at: string;
}
export interface VerifyPasswordChangeResult {
verification_proof: string;
}
export class PasswordChangeService {
private readonly CODE_TTL_MS = ms('10 minutes');
private readonly RESEND_COOLDOWN_MS = ms('30 seconds');
constructor(
private readonly repo: PasswordChangeRepository,
private readonly emailService: IEmailService,
private readonly authService: IAuthServiceForPasswordChange,
private readonly userAccountRepository: IUserAccountRepository,
private readonly rateLimitService: IRateLimitService,
) {}
async start(user: User): Promise<StartPasswordChangeResult> {
if (!user.email) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.MUST_HAVE_EMAIL_TO_CHANGE_IT);
}
await this.ensureRateLimit(`password_change:start:${user.id}`, 3, ms('15 minutes'));
const ticket = this.generateTicket();
const now = new Date();
const code = this.generateCode();
const codeExpiresAt = new Date(now.getTime() + this.CODE_TTL_MS);
await this.emailService.sendPasswordChangeVerification(user.email, user.username, code, user.locale);
await this.repo.createTicket({
ticket,
user_id: user.id,
code,
code_sent_at: now,
code_expires_at: codeExpiresAt,
verified: false,
verification_proof: null,
status: 'pending',
created_at: now,
updated_at: now,
});
return {
ticket,
code_expires_at: codeExpiresAt.toISOString(),
resend_available_at: new Date(now.getTime() + this.RESEND_COOLDOWN_MS).toISOString(),
};
}
async resend(user: User, ticket: string): Promise<void> {
const row = await this.getTicketForUser(ticket, user.id);
if (!user.email) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.MUST_HAVE_EMAIL_TO_CHANGE_IT);
}
this.assertCooldown(row.code_sent_at);
await this.ensureRateLimit(`password_change:resend:${user.id}`, 3, ms('15 minutes'));
const now = new Date();
const code = this.generateCode();
const codeExpiresAt = new Date(now.getTime() + this.CODE_TTL_MS);
await this.emailService.sendPasswordChangeVerification(user.email, user.username, code, user.locale);
row.code = code;
row.code_sent_at = now;
row.code_expires_at = codeExpiresAt;
row.updated_at = now;
await this.repo.updateTicket(row);
}
async verify(user: User, ticket: string, code: string): Promise<VerifyPasswordChangeResult> {
const row = await this.getTicketForUser(ticket, user.id);
if (row.verified && row.verification_proof) {
return {verification_proof: row.verification_proof};
}
if (!row.code || !row.code_expires_at) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_NOT_ISSUED);
}
if (row.code_expires_at.getTime() < Date.now()) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_EXPIRED);
}
if (row.code !== code.trim()) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_VERIFICATION_CODE);
}
const now = new Date();
const verificationProof = this.generateProof();
row.verified = true;
row.verification_proof = verificationProof;
row.status = 'verified';
row.updated_at = now;
await this.repo.updateTicket(row);
return {verification_proof: verificationProof};
}
async complete(user: User, ticket: string, verificationProof: string, newPassword: string): Promise<void> {
const row = await this.getTicketForUser(ticket, user.id);
if (!row.verified || !row.verification_proof) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_TICKET);
}
if (row.verification_proof !== verificationProof) {
throw InputValidationError.fromCode('verification_proof', ValidationErrorCodes.INVALID_PROOF_TOKEN);
}
if (await this.authService.isPasswordPwned(newPassword)) {
throw InputValidationError.fromCode('new_password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
}
const newPasswordHash = await this.authService.hashPassword(newPassword);
await this.userAccountRepository.patchUpsert(user.id, {
password_hash: newPasswordHash,
password_last_changed_at: new Date(),
});
const now = new Date();
row.status = 'completed';
row.updated_at = now;
await this.repo.updateTicket(row);
}
private async getTicketForUser(ticket: string, userId: bigint) {
const row = await this.repo.findTicket(ticket);
if (!row || row.user_id !== userId) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_TICKET);
}
if (row.status === 'completed') {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.TICKET_ALREADY_COMPLETED);
}
return row;
}
private generateCode(): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let raw = '';
while (raw.length < 8) {
const byte = crypto.randomBytes(1)[0];
const idx = byte % alphabet.length;
raw += alphabet[idx];
}
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}`;
}
private generateTicket(): string {
return crypto.randomUUID();
}
private generateProof(): string {
return crypto.randomUUID();
}
private assertCooldown(sentAt: Date | null | undefined) {
if (!sentAt) return;
const nextAllowed = sentAt.getTime() + this.RESEND_COOLDOWN_MS;
if (nextAllowed > Date.now()) {
const retryAfter = Math.ceil((nextAllowed - Date.now()) / 1000);
throw new RateLimitError({
message: 'Please wait before resending.',
retryAfter,
limit: 1,
resetTime: new Date(nextAllowed),
});
}
}
private async ensureRateLimit(identifier: string, maxAttempts: number, windowMs: number) {
const result = await this.rateLimitService.checkLimit({identifier, maxAttempts, windowMs});
if (!result.allowed) {
throw new RateLimitError({
message: 'Too many attempts. Please try again later.',
retryAfter: result.retryAfter || 0,
limit: result.limit,
resetTime: result.resetTime,
});
}
}
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAccountDeletionQueueService';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
import {hasPartialUserFieldsChanged} from '@fluxer/api/src/user/UserMappers';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import {UserOwnsGuildsError} from '@fluxer/errors/src/domains/guild/UserOwnsGuildsError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {ms} from 'itty-time';
interface UserAccountLifecycleServiceDeps {
userAccountRepository: IUserAccountRepository;
guildRepository: IGuildRepositoryAggregate;
authService: AuthService;
emailService: IEmailService;
updatePropagator: UserAccountUpdatePropagator;
kvDeletionQueue: KVAccountDeletionQueueService;
}
export class UserAccountLifecycleService {
constructor(private readonly deps: UserAccountLifecycleServiceDeps) {}
async selfDisable(userId: UserID): Promise<void> {
const user = await this.deps.userAccountRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const updatedUser = await this.deps.userAccountRepository.patchUpsert(
userId,
{
flags: user.flags | UserFlags.DISABLED,
},
user.toRow(),
);
await this.deps.authService.terminateAllUserSessions(userId);
if (updatedUser) {
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.deps.updatePropagator.updateUserCache(updatedUser);
}
}
}
async selfDelete(userId: UserID): Promise<void> {
const user = await this.deps.userAccountRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const ownedGuildIds = await this.deps.guildRepository.listOwnedGuildIds(userId);
if (ownedGuildIds.length > 0) {
throw new UserOwnsGuildsError();
}
const gracePeriodMs = Config.deletionGracePeriodHours * ms('1 hour');
const pendingDeletionAt = new Date(Date.now() + gracePeriodMs);
const updatedUser = await this.deps.userAccountRepository.patchUpsert(
userId,
{
flags: user.flags | UserFlags.SELF_DELETED,
pending_deletion_at: pendingDeletionAt,
},
user.toRow(),
);
await this.deps.userAccountRepository.addPendingDeletion(userId, pendingDeletionAt, DeletionReasons.USER_REQUESTED);
await this.deps.kvDeletionQueue.scheduleDeletion(userId, pendingDeletionAt, DeletionReasons.USER_REQUESTED);
if (user.email) {
await this.deps.emailService.sendSelfDeletionScheduledEmail(
user.email,
user.username,
pendingDeletionAt,
user.locale,
);
}
await this.deps.authService.terminateAllUserSessions(userId);
if (updatedUser) {
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.deps.updatePropagator.updateUserCache(updatedUser);
}
}
}
}

View File

@@ -0,0 +1,310 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {IConnectionRepository} from '@fluxer/api/src/connection/IConnectionRepository';
import type {UserConnectionRow} from '@fluxer/api/src/database/types/ConnectionTypes';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {ConnectionVisibilityFlags} from '@fluxer/constants/src/ConnectionConstants';
import {RelationshipTypes, UserFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
interface UserAccountLookupServiceDeps {
userAccountRepository: IUserAccountRepository;
userChannelRepository: IUserChannelRepository;
userRelationshipRepository: IUserRelationshipRepository;
guildRepository: IGuildRepositoryAggregate;
guildService: GuildService;
discriminatorService: IDiscriminatorService;
connectionRepository: IConnectionRepository;
}
export class UserAccountLookupService {
constructor(private readonly deps: UserAccountLookupServiceDeps) {}
async findUnique(userId: UserID): Promise<User | null> {
return await this.deps.userAccountRepository.findUnique(userId);
}
async findUniqueAssert(userId: UserID): Promise<User> {
return await this.deps.userAccountRepository.findUniqueAssert(userId);
}
async getUserProfile(params: {
userId: UserID;
targetId: UserID;
guildId?: GuildID;
withMutualFriends?: boolean;
withMutualGuilds?: boolean;
requestCache: RequestCache;
}): Promise<{
user: User;
guildMember?: GuildMemberResponse | null;
guildMemberDomain?: GuildMember | null;
premiumType?: number;
premiumSince?: Date;
premiumLifetimeSequence?: number;
mutualFriends?: Array<User>;
mutualGuilds?: Array<{id: string; nick: string | null}>;
connections?: Array<UserConnectionRow>;
}> {
const {userId, targetId, guildId, withMutualFriends, withMutualGuilds, requestCache} = params;
const user = await this.deps.userAccountRepository.findUnique(targetId);
if (!user) throw new UnknownUserError();
if (userId !== targetId) {
await this.validateProfileAccess(userId, targetId, user);
}
let guildMember: GuildMemberResponse | null = null;
let guildMemberDomain: GuildMember | null = null;
if (guildId != null) {
guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId);
if (guildMemberDomain) {
guildMember = await this.deps.guildService.getMember({
userId,
targetId,
guildId,
requestCache,
});
}
}
let premiumType = user.premiumType ?? undefined;
let premiumSince = user.premiumSince ?? undefined;
let premiumLifetimeSequence = user.premiumLifetimeSequence ?? undefined;
if (user.flags & UserFlags.PREMIUM_BADGE_HIDDEN) {
premiumType = undefined;
premiumSince = undefined;
premiumLifetimeSequence = undefined;
} else {
if (user.premiumType === UserPremiumTypes.LIFETIME) {
if (user.flags & UserFlags.PREMIUM_BADGE_MASKED) {
premiumType = UserPremiumTypes.SUBSCRIPTION;
}
if (user.flags & UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN) {
premiumLifetimeSequence = undefined;
}
}
if (user.flags & UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN) {
premiumSince = undefined;
}
}
const [mutualFriends, mutualGuilds, connections] = await Promise.all([
withMutualFriends && userId !== targetId ? this.getMutualFriends(userId, targetId) : undefined,
withMutualGuilds && userId !== targetId ? this.getMutualGuilds(userId, targetId) : undefined,
this.getVisibleConnections(userId, targetId),
]);
return {
user,
guildMember,
guildMemberDomain,
premiumType,
premiumSince,
premiumLifetimeSequence,
mutualFriends,
mutualGuilds,
connections,
};
}
private async validateProfileAccess(userId: UserID, targetId: UserID, targetUser: User): Promise<void> {
if (targetUser.isBot) {
return;
}
const friendship = await this.deps.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.FRIEND,
);
if (friendship) {
return;
}
const incomingRequest = await this.deps.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.INCOMING_REQUEST,
);
if (incomingRequest) {
return;
}
const [userGuildIds, targetGuildIds] = await Promise.all([
this.deps.userAccountRepository.getUserGuildIds(userId),
this.deps.userAccountRepository.getUserGuildIds(targetId),
]);
const userGuildIdSet = new Set(userGuildIds.map((id) => id.toString()));
const hasMutualGuild = targetGuildIds.some((id) => userGuildIdSet.has(id.toString()));
if (hasMutualGuild) {
return;
}
if (await this.hasSharedGroupDm(userId, targetId)) {
return;
}
throw new MissingAccessError();
}
private async hasSharedGroupDm(userId: UserID, targetId: UserID): Promise<boolean> {
const privateChannels = await this.deps.userChannelRepository.listPrivateChannels(userId);
return privateChannels.some(
(channel) => channel.type === ChannelTypes.GROUP_DM && channel.recipientIds.has(targetId),
);
}
private async getMutualFriends(userId: UserID, targetId: UserID): Promise<Array<User>> {
const [userRelationships, targetRelationships] = await Promise.all([
this.deps.userRelationshipRepository.listRelationships(userId),
this.deps.userRelationshipRepository.listRelationships(targetId),
]);
const userFriendIds = new Set(
userRelationships
.filter((rel) => rel.type === RelationshipTypes.FRIEND)
.map((rel) => rel.targetUserId.toString()),
);
const mutualFriendIds = targetRelationships
.filter((rel) => rel.type === RelationshipTypes.FRIEND && userFriendIds.has(rel.targetUserId.toString()))
.map((rel) => rel.targetUserId);
if (mutualFriendIds.length === 0) {
return [];
}
const users = await this.deps.userAccountRepository.listUsers(mutualFriendIds);
return users.sort((a, b) => this.compareUsersByIdDesc(a, b));
}
private compareUsersByIdDesc(a: User, b: User): number {
if (b.id > a.id) return 1;
if (b.id < a.id) return -1;
return 0;
}
private async getMutualGuilds(userId: UserID, targetId: UserID): Promise<Array<{id: string; nick: string | null}>> {
const [userGuildIds, targetGuildIds] = await Promise.all([
this.deps.userAccountRepository.getUserGuildIds(userId),
this.deps.userAccountRepository.getUserGuildIds(targetId),
]);
const userGuildIdSet = new Set(userGuildIds.map((id) => id.toString()));
const mutualGuildIds = targetGuildIds.filter((id) => userGuildIdSet.has(id.toString()));
if (mutualGuildIds.length === 0) {
return [];
}
const memberPromises = mutualGuildIds.map((guildId) => this.deps.guildRepository.getMember(guildId, targetId));
const members = await Promise.all(memberPromises);
return mutualGuildIds.map((guildId, index) => ({
id: guildId.toString(),
nick: members[index]?.nickname ?? null,
}));
}
async generateUniqueDiscriminator(username: string): Promise<number> {
const usedDiscriminators = await this.deps.userAccountRepository.findDiscriminatorsByUsername(username);
for (let i = 1; i <= 9999; i++) {
if (!usedDiscriminators.has(i)) return i;
}
throw new Error('No available discriminators for this username');
}
async checkUsernameDiscriminatorAvailability(params: {username: string; discriminator: number}): Promise<boolean> {
const {username, discriminator} = params;
const isAvailable = await this.deps.discriminatorService.isDiscriminatorAvailableForUsername(
username,
discriminator,
);
return !isAvailable;
}
private async getVisibleConnections(viewerId: UserID, targetId: UserID): Promise<Array<UserConnectionRow>> {
const connections = await this.deps.connectionRepository.findByUserId(targetId);
const verified = connections.filter((connection) => connection.verified);
if (viewerId === targetId) {
return verified;
}
const [isFriend, hasMutualGuild] = await Promise.all([
this.areFriends(viewerId, targetId),
this.haveMutualGuild(viewerId, targetId),
]);
return verified.filter((connection) => {
const flags = connection.visibility_flags;
if (flags & ConnectionVisibilityFlags.EVERYONE) {
return true;
}
if (flags & ConnectionVisibilityFlags.FRIENDS && isFriend) {
return true;
}
if (flags & ConnectionVisibilityFlags.MUTUAL_GUILDS && hasMutualGuild) {
return true;
}
return false;
});
}
private async areFriends(userId1: UserID, userId2: UserID): Promise<boolean> {
const friendship = await this.deps.userRelationshipRepository.getRelationship(
userId1,
userId2,
RelationshipTypes.FRIEND,
);
return friendship !== null;
}
private async haveMutualGuild(userId1: UserID, userId2: UserID): Promise<boolean> {
const [user1GuildIds, user2GuildIds] = await Promise.all([
this.deps.userAccountRepository.getUserGuildIds(userId1),
this.deps.userAccountRepository.getUserGuildIds(userId2),
]);
const user1GuildIdSet = new Set(user1GuildIds.map((id) => id.toString()));
return user2GuildIds.some((id) => user1GuildIdSet.has(id.toString()));
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
import type {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
interface UserAccountNotesServiceDeps {
userAccountRepository: IUserAccountRepository;
userRelationshipRepository: IUserRelationshipRepository;
updatePropagator: UserAccountUpdatePropagator;
}
export class UserAccountNotesService {
constructor(private readonly deps: UserAccountNotesServiceDeps) {}
async getUserNote(params: {userId: UserID; targetId: UserID}): Promise<{note: string} | null> {
const {userId, targetId} = params;
const note = await this.deps.userRelationshipRepository.getUserNote(userId, targetId);
return note ? {note: note.note} : null;
}
async getUserNotes(userId: UserID): Promise<Record<string, string>> {
const notes = await this.deps.userRelationshipRepository.getUserNotes(userId);
return Object.fromEntries(Array.from(notes.entries()).map(([k, v]) => [k.toString(), v]));
}
async setUserNote(params: {userId: UserID; targetId: UserID; note: string | null}): Promise<void> {
const {userId, targetId, note} = params;
const targetUser = await this.deps.userAccountRepository.findUnique(targetId);
if (!targetUser) throw new UnknownUserError();
if (note) {
await this.deps.userRelationshipRepository.upsertUserNote(userId, targetId, note);
} else {
await this.deps.userRelationshipRepository.clearUserNote(userId, targetId);
}
await this.deps.updatePropagator.dispatchUserNoteUpdate({userId, targetId, note: note ?? ''});
}
}

View File

@@ -0,0 +1,481 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {EntityAssetService, PreparedAssetUpload} from '@fluxer/api/src/infrastructure/EntityAssetService';
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {User} from '@fluxer/api/src/models/User';
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
import {deriveDominantAvatarColor} from '@fluxer/api/src/utils/AvatarColorUtils';
import * as EmojiUtils from '@fluxer/api/src/utils/EmojiUtils';
import {MAX_BIO_LENGTH} from '@fluxer/constants/src/LimitConstants';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {UserUpdateRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import {ms} from 'itty-time';
interface UserUpdateMetadata {
invalidateAuthSessions?: boolean;
}
type UserFieldUpdates = Partial<UserRow>;
export interface ProfileUpdateResult {
updates: UserFieldUpdates;
metadata: UserUpdateMetadata;
preparedAvatarUpload: PreparedAssetUpload | null;
preparedBannerUpload: PreparedAssetUpload | null;
}
interface UserAccountProfileServiceDeps {
userAccountRepository: IUserAccountRepository;
guildRepository: IGuildRepositoryAggregate;
entityAssetService: EntityAssetService;
rateLimitService: IRateLimitService;
updatePropagator: UserAccountUpdatePropagator;
limitConfigService: LimitConfigService;
}
export class UserAccountProfileService {
constructor(private readonly deps: UserAccountProfileServiceDeps) {}
async processProfileUpdates(params: {user: User; data: UserUpdateRequest}): Promise<ProfileUpdateResult> {
const {user, data} = params;
const updates: UserFieldUpdates = {
avatar_hash: user.avatarHash,
banner_hash: user.bannerHash,
flags: user.flags,
};
const metadata: UserUpdateMetadata = {};
let preparedAvatarUpload: PreparedAssetUpload | null = null;
let preparedBannerUpload: PreparedAssetUpload | null = null;
if (data.bio !== undefined) {
await this.processBioUpdate({user, bio: data.bio, updates});
}
if (data.pronouns !== undefined) {
await this.processPronounsUpdate({user, pronouns: data.pronouns, updates});
}
if (data.accent_color !== undefined) {
await this.processAccentColorUpdate({user, accentColor: data.accent_color, updates});
}
if (data.avatar !== undefined) {
preparedAvatarUpload = await this.processAvatarUpdate({user, avatar: data.avatar, updates});
}
if (data.banner !== undefined) {
try {
preparedBannerUpload = await this.processBannerUpdate({user, banner: data.banner, updates});
} catch (error) {
if (preparedAvatarUpload) {
await this.deps.entityAssetService.rollbackAssetUpload(preparedAvatarUpload);
}
throw error;
}
}
if (!user.isBot) {
this.processPremiumBadgeFlags({user, data, updates});
this.processPremiumOnboardingDismissal({user, data, updates});
this.processGiftInventoryRead({user, data, updates});
this.processUsedMobileClient({user, data, updates});
}
return {updates, metadata, preparedAvatarUpload, preparedBannerUpload};
}
async commitAssetChanges(result: ProfileUpdateResult): Promise<void> {
if (result.preparedAvatarUpload) {
await this.deps.entityAssetService.commitAssetChange({
prepared: result.preparedAvatarUpload,
deferDeletion: true,
});
}
if (result.preparedBannerUpload) {
await this.deps.entityAssetService.commitAssetChange({
prepared: result.preparedBannerUpload,
deferDeletion: true,
});
}
}
async rollbackAssetChanges(result: ProfileUpdateResult): Promise<void> {
if (result.preparedAvatarUpload) {
await this.deps.entityAssetService.rollbackAssetUpload(result.preparedAvatarUpload);
}
if (result.preparedBannerUpload) {
await this.deps.entityAssetService.rollbackAssetUpload(result.preparedBannerUpload);
}
}
private async processBioUpdate(params: {user: User; bio: string | null; updates: UserFieldUpdates}): Promise<void> {
const {user, bio, updates} = params;
if (bio !== user.bio) {
getMetricsService().counter({name: 'fluxer.users.bio_updated'});
const bioRateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `bio_change:${user.id}`,
maxAttempts: 25,
windowMs: ms('30 minutes'),
});
if (!bioRateLimit.allowed) {
const minutes = Math.ceil((bioRateLimit.retryAfter || 0) / 60);
throw InputValidationError.fromCode('bio', ValidationErrorCodes.BIO_CHANGED_TOO_MANY_TIMES, {minutes});
}
const ctx = createLimitMatchContext({user});
const maxBioLength = resolveLimitSafe(
this.deps.limitConfigService.getConfigSnapshot(),
ctx,
'max_bio_length',
MAX_BIO_LENGTH,
);
if (bio && bio.length > maxBioLength) {
throw InputValidationError.fromCode('bio', ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH, {
maxLength: maxBioLength,
});
}
let sanitizedBio = bio;
if (bio) {
sanitizedBio = await EmojiUtils.sanitizeCustomEmojis({
content: bio,
userId: user.id,
webhookId: null,
guildId: null,
userRepository: this.deps.userAccountRepository,
guildRepository: this.deps.guildRepository,
limitConfigService: this.deps.limitConfigService,
});
}
updates.bio = sanitizedBio;
}
}
private async processPronounsUpdate(params: {
user: User;
pronouns: string | null;
updates: UserFieldUpdates;
}): Promise<void> {
const {user, pronouns, updates} = params;
if (pronouns !== user.pronouns) {
getMetricsService().counter({name: 'fluxer.users.pronouns_updated'});
const pronounsRateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `pronouns_change:${user.id}`,
maxAttempts: 25,
windowMs: ms('30 minutes'),
});
if (!pronounsRateLimit.allowed) {
const minutes = Math.ceil((pronounsRateLimit.retryAfter || 0) / 60);
throw InputValidationError.fromCode('pronouns', ValidationErrorCodes.PRONOUNS_CHANGED_TOO_MANY_TIMES, {
minutes,
});
}
updates.pronouns = pronouns;
}
}
private async processAccentColorUpdate(params: {
user: User;
accentColor: number | null;
updates: UserFieldUpdates;
}): Promise<void> {
const {user, accentColor, updates} = params;
if (accentColor !== user.accentColor) {
getMetricsService().counter({name: 'fluxer.users.accent_color_updated'});
const accentColorRateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `accent_color_change:${user.id}`,
maxAttempts: 25,
windowMs: ms('30 minutes'),
});
if (!accentColorRateLimit.allowed) {
const minutes = Math.ceil((accentColorRateLimit.retryAfter || 0) / 60);
throw InputValidationError.fromCode('accent_color', ValidationErrorCodes.ACCENT_COLOR_CHANGED_TOO_MANY_TIMES, {
minutes,
});
}
updates.accent_color = accentColor;
}
}
private async processAvatarUpdate(params: {
user: User;
avatar: string | null;
updates: UserFieldUpdates;
}): Promise<PreparedAssetUpload | null> {
return await withBusinessSpan(
'fluxer.user.avatar_update',
'fluxer.users.avatars_updated',
{
user_id: params.user.id.toString(),
avatar_type: params.avatar ? 'custom' : 'default',
},
async () => {
const {user, avatar, updates} = params;
if (avatar === null) {
updates.avatar_hash = null;
updates.avatar_color = null;
if (user.avatarHash) {
return await this.deps.entityAssetService.prepareAssetUpload({
assetType: 'avatar',
entityType: 'user',
entityId: user.id,
previousHash: user.avatarHash,
base64Image: null,
errorPath: 'avatar',
});
}
return null;
}
const avatarRateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `avatar_change:${user.id}`,
maxAttempts: 25,
windowMs: ms('30 minutes'),
});
if (!avatarRateLimit.allowed) {
const minutes = Math.ceil((avatarRateLimit.retryAfter || 0) / 60);
throw InputValidationError.fromCode('avatar', ValidationErrorCodes.AVATAR_CHANGED_TOO_MANY_TIMES, {minutes});
}
const prepared = await this.deps.entityAssetService.prepareAssetUpload({
assetType: 'avatar',
entityType: 'user',
entityId: user.id,
previousHash: user.avatarHash,
base64Image: avatar,
errorPath: 'avatar',
});
const ctx = createLimitMatchContext({user});
const hasAnimatedAvatar = resolveLimitSafe(
this.deps.limitConfigService.getConfigSnapshot(),
ctx,
'feature_animated_avatar',
0,
);
if (prepared.isAnimated && hasAnimatedAvatar === 0) {
await this.deps.entityAssetService.rollbackAssetUpload(prepared);
throw InputValidationError.fromCode('avatar', ValidationErrorCodes.ANIMATED_AVATARS_REQUIRE_PREMIUM);
}
if (prepared.imageBuffer) {
const derivedColor = await deriveDominantAvatarColor(prepared.imageBuffer);
if (derivedColor !== user.avatarColor) {
updates.avatar_color = derivedColor;
}
}
if (prepared.newHash !== user.avatarHash) {
updates.avatar_hash = prepared.newHash;
return prepared;
}
return null;
},
);
}
private async processBannerUpdate(params: {
user: User;
banner: string | null;
updates: UserFieldUpdates;
}): Promise<PreparedAssetUpload | null> {
const {user, banner, updates} = params;
if (banner === null) {
updates.banner_color = null;
}
getMetricsService().counter({name: 'fluxer.users.banner_updated'});
const ctx = createLimitMatchContext({user});
const hasAnimatedBanner = resolveLimitSafe(
this.deps.limitConfigService.getConfigSnapshot(),
ctx,
'feature_animated_banner',
0,
);
if (banner && hasAnimatedBanner === 0) {
throw InputValidationError.fromCode('banner', ValidationErrorCodes.BANNERS_REQUIRE_PREMIUM);
}
const bannerRateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `banner_change:${user.id}`,
maxAttempts: 25,
windowMs: ms('30 minutes'),
});
if (!bannerRateLimit.allowed) {
const minutes = Math.ceil((bannerRateLimit.retryAfter || 0) / 60);
throw InputValidationError.fromCode('banner', ValidationErrorCodes.BANNER_CHANGED_TOO_MANY_TIMES, {minutes});
}
const prepared = await this.deps.entityAssetService.prepareAssetUpload({
assetType: 'banner',
entityType: 'user',
entityId: user.id,
previousHash: user.bannerHash,
base64Image: banner,
errorPath: 'banner',
});
if (prepared.isAnimated && hasAnimatedBanner === 0) {
await this.deps.entityAssetService.rollbackAssetUpload(prepared);
throw InputValidationError.fromCode('banner', ValidationErrorCodes.ANIMATED_AVATARS_REQUIRE_PREMIUM);
}
if (banner !== null && prepared.imageBuffer) {
const derivedColor = await deriveDominantAvatarColor(prepared.imageBuffer);
if (derivedColor !== user.bannerColor) {
updates.banner_color = derivedColor;
}
}
if (prepared.newHash !== user.bannerHash) {
updates.banner_hash = prepared.newHash;
return prepared;
}
return null;
}
private processPremiumBadgeFlags(params: {user: User; data: UserUpdateRequest; updates: UserFieldUpdates}): void {
const {user, data, updates} = params;
let flagsUpdated = false;
let newFlags = user.flags;
if (data.premium_badge_hidden !== undefined) {
if (data.premium_badge_hidden) {
newFlags = newFlags | UserFlags.PREMIUM_BADGE_HIDDEN;
} else {
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_HIDDEN;
}
flagsUpdated = true;
}
if (data.premium_badge_masked !== undefined) {
if (data.premium_badge_masked) {
newFlags = newFlags | UserFlags.PREMIUM_BADGE_MASKED;
} else {
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_MASKED;
}
flagsUpdated = true;
}
if (data.premium_badge_timestamp_hidden !== undefined) {
if (data.premium_badge_timestamp_hidden) {
newFlags = newFlags | UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN;
} else {
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN;
}
flagsUpdated = true;
}
if (data.premium_badge_sequence_hidden !== undefined) {
if (data.premium_badge_sequence_hidden) {
newFlags = newFlags | UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN;
} else {
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN;
}
flagsUpdated = true;
}
if (data.premium_enabled_override !== undefined) {
if (!(user.flags & UserFlags.STAFF)) {
throw new MissingAccessError();
}
if (data.premium_enabled_override) {
newFlags = newFlags | UserFlags.PREMIUM_ENABLED_OVERRIDE;
} else {
newFlags = newFlags & ~UserFlags.PREMIUM_ENABLED_OVERRIDE;
}
flagsUpdated = true;
}
if (flagsUpdated) {
updates.flags = newFlags;
}
}
private processPremiumOnboardingDismissal(params: {
user: User;
data: UserUpdateRequest;
updates: UserFieldUpdates;
}): void {
const {data, updates} = params;
if (data.has_dismissed_premium_onboarding !== undefined) {
if (data.has_dismissed_premium_onboarding) {
updates.premium_onboarding_dismissed_at = new Date();
}
}
}
private processGiftInventoryRead(params: {user: User; data: UserUpdateRequest; updates: UserFieldUpdates}): void {
const {user, data, updates} = params;
if (data.has_unread_gift_inventory === false) {
updates.gift_inventory_client_seq = user.giftInventoryServerSeq;
}
}
private processUsedMobileClient(params: {user: User; data: UserUpdateRequest; updates: UserFieldUpdates}): void {
const {user, data, updates} = params;
if (data.used_mobile_client !== undefined) {
let newFlags = updates.flags ?? user.flags;
if (data.used_mobile_client) {
newFlags = newFlags | UserFlags.USED_MOBILE_CLIENT;
} else {
newFlags = newFlags & ~UserFlags.USED_MOBILE_CLIENT;
}
updates.flags = newFlags;
}
}
}

View File

@@ -0,0 +1,324 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {AuthMfaService} from '@fluxer/api/src/auth/services/AuthMfaService';
import {requireSudoMode, type SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
import {createChannelID, createGuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
import type {UserConnectionRow} from '@fluxer/api/src/database/types/ConnectionTypes';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import {Logger} from '@fluxer/api/src/Logger';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {User} from '@fluxer/api/src/models/User';
import type {HonoEnv} from '@fluxer/api/src/types/HonoEnv';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import type {EmailChangeService} from '@fluxer/api/src/user/services/EmailChangeService';
import type {UserService} from '@fluxer/api/src/user/services/UserService';
import {mapUserToPartialResponseWithCache} from '@fluxer/api/src/user/UserCacheHelpers';
import {createPremiumClearPatch, shouldStripExpiredPremium} from '@fluxer/api/src/user/UserHelpers';
import {
mapGuildMemberToProfileResponse,
mapUserToPrivateResponse,
mapUserToProfileResponse,
} from '@fluxer/api/src/user/UserMappers';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
import {AccountSuspiciousActivityError} from '@fluxer/errors/src/domains/user/AccountSuspiciousActivityError';
import type {ConnectionResponse} from '@fluxer/schema/src/domains/connection/ConnectionSchemas';
import type {UserUpdateWithVerificationRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {UserPrivateResponse, UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {Context} from 'hono';
import type {z} from 'zod';
export type UserUpdateWithVerificationRequestData = z.infer<typeof UserUpdateWithVerificationRequest>;
type UserUpdatePayload = Omit<
UserUpdateWithVerificationRequestData,
'mfa_method' | 'mfa_code' | 'webauthn_response' | 'webauthn_challenge' | 'email_token'
>;
interface UserProfileParams {
currentUserId: UserID;
targetUserId: UserID;
guildId?: bigint;
withMutualFriends?: boolean;
withMutualGuilds?: boolean;
requestCache: RequestCache;
}
export class UserAccountRequestService {
constructor(
private readonly authService: AuthService,
private readonly authMfaService: AuthMfaService,
private readonly emailChangeService: EmailChangeService,
private readonly userService: UserService,
private readonly userRepository: IUserRepository,
private readonly userCacheService: UserCacheService,
private readonly mediaService: IMediaService,
) {}
getCurrentUserResponse(params: {
authTokenType?: 'session' | 'bearer' | 'bot' | 'admin_api_key';
oauthBearerScopes?: Set<string> | null;
user?: User;
}): UserPrivateResponse {
const tokenType = params.authTokenType;
if (tokenType === 'bearer') {
const bearerUser = params.user;
if (!bearerUser) {
throw new UnauthorizedError();
}
this.enforceUserAccess(bearerUser);
const includeEmail = params.oauthBearerScopes?.has('email') ?? false;
const response = mapUserToPrivateResponse(bearerUser);
if (!includeEmail) {
response.email = null;
}
return response;
}
const user = params.user;
if (user) {
this.enforceUserAccess(user);
return mapUserToPrivateResponse(user);
}
throw new UnauthorizedError();
}
async updateCurrentUser(params: {
ctx: Context<HonoEnv>;
user: User;
body: UserUpdateWithVerificationRequestData;
authSession: AuthSession;
}): Promise<UserPrivateResponse> {
const {ctx, user, body, authSession} = params;
const oldEmail = user.email;
const {
mfa_method: _mfaMethod,
mfa_code: _mfaCode,
webauthn_response: _webauthnResponse,
webauthn_challenge: _webauthnChallenge,
email_token: emailToken,
...userUpdateDataRest
} = body;
let userUpdateData: UserUpdatePayload = userUpdateDataRest;
if (userUpdateData.email !== undefined) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_MUST_BE_CHANGED_VIA_TOKEN);
}
const emailTokenProvided = emailToken !== undefined;
const isUnclaimed = user.isUnclaimedAccount();
if (!isUnclaimed && userUpdateData.new_password !== undefined && !userUpdateData.password) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
}
if (isUnclaimed) {
const allowed = new Set(['username', 'discriminator', 'new_password']);
const disallowedField = Object.keys(userUpdateData).find((key) => !allowed.has(key));
if (disallowedField) {
throw InputValidationError.fromCode(
disallowedField,
ValidationErrorCodes.UNCLAIMED_ACCOUNTS_CAN_ONLY_SET_EMAIL_VIA_TOKEN,
);
}
}
let emailFromToken: string | null = null;
let emailVerifiedViaToken = false;
const needsVerification = this.requiresSensitiveUserVerification(user, userUpdateData, emailTokenProvided);
let sudoResult: SudoVerificationResult | null = null;
if (needsVerification) {
sudoResult = await requireSudoMode(ctx, user, body, this.authService, this.authMfaService);
}
if (emailTokenProvided && emailToken) {
emailFromToken = await this.emailChangeService.consumeToken(user.id, emailToken);
userUpdateData = {...userUpdateData, email: emailFromToken};
emailVerifiedViaToken = true;
}
const updatedUser = await this.userService.update({
user,
oldAuthSession: authSession,
data: userUpdateData,
request: ctx.req.raw,
sudoContext: sudoResult ?? undefined,
emailVerifiedViaToken,
});
if (emailFromToken && oldEmail && updatedUser.email && oldEmail.toLowerCase() !== updatedUser.email.toLowerCase()) {
try {
await this.authService.issueEmailRevertToken(updatedUser, oldEmail, updatedUser.email);
} catch (error) {
Logger.warn({error, userId: updatedUser.id}, 'Failed to issue email revert token');
}
}
return mapUserToPrivateResponse(updatedUser);
}
async preloadMessages(params: {
userId: UserID;
channels: ReadonlyArray<bigint>;
requestCache: RequestCache;
}): Promise<Record<string, unknown>> {
const channelIds = params.channels.map((channelId) => createChannelID(channelId));
const messages = await this.userService.preloadDMMessages({
userId: params.userId,
channelIds,
});
const mappingPromises = Object.entries(messages).map(async ([channelId, message]) => {
const mappedMessage = message
? await mapMessageToResponse({
message,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
mediaService: this.mediaService,
currentUserId: params.userId,
})
: null;
return [channelId, mappedMessage] as const;
});
const mappedEntries = await Promise.all(mappingPromises);
return Object.fromEntries(mappedEntries);
}
async getUserProfile(params: UserProfileParams): Promise<UserProfileFullResponse> {
const guildId = params.guildId ? createGuildID(params.guildId) : undefined;
const profile = await this.userService.getUserProfile({
userId: params.currentUserId,
targetId: params.targetUserId,
guildId,
withMutualFriends: params.withMutualFriends,
withMutualGuilds: params.withMutualGuilds,
requestCache: params.requestCache,
});
let profileUser = profile.user;
let premiumType = profile.premiumType;
let premiumSince = profile.premiumSince;
let premiumLifetimeSequence = profile.premiumLifetimeSequence;
if (shouldStripExpiredPremium(profileUser)) {
try {
const sanitizedUser = await this.userRepository.patchUpsert(
profileUser.id,
createPremiumClearPatch(),
profileUser.toRow(),
);
if (sanitizedUser) {
profileUser = sanitizedUser;
profile.user = sanitizedUser;
premiumType = undefined;
premiumSince = undefined;
premiumLifetimeSequence = undefined;
}
} catch (error) {
Logger.warn(
{userId: profileUser.id.toString(), error},
'Failed to sanitize expired premium fields before returning profile',
);
}
}
const userProfile = mapUserToProfileResponse(profileUser);
const guildMemberProfile = mapGuildMemberToProfileResponse(profile.guildMemberDomain ?? null);
const mutualFriends = profile.mutualFriends
? await Promise.all(
profile.mutualFriends.map((user) =>
mapUserToPartialResponseWithCache({
user,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
}),
),
)
: undefined;
const connectedAccounts = profile.connections ? this.mapConnectionsToResponse(profile.connections) : undefined;
return {
user: await mapUserToPartialResponseWithCache({
user: profileUser,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
}),
user_profile: userProfile,
guild_member: profile.guildMember ?? undefined,
guild_member_profile: guildMemberProfile ?? undefined,
premium_type: premiumType,
premium_since: premiumSince?.toISOString(),
premium_lifetime_sequence: premiumLifetimeSequence,
mutual_friends: mutualFriends,
mutual_guilds: profile.mutualGuilds,
connected_accounts: connectedAccounts,
};
}
checkTagAvailability(params: {currentUser: User; username: string; discriminator: number}): boolean {
const currentUser = params.currentUser;
const discriminator = params.discriminator;
if (
params.username.toLowerCase() === currentUser.username.toLowerCase() &&
discriminator === currentUser.discriminator
) {
return false;
}
return true;
}
private enforceUserAccess(user: User): void {
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
throw new AccountSuspiciousActivityError(user.suspiciousActivityFlags);
}
}
private requiresSensitiveUserVerification(user: User, data: UserUpdatePayload, emailTokenProvided: boolean): boolean {
const isUnclaimed = user.isUnclaimedAccount();
const usernameChanged = data.username !== undefined && data.username !== user.username;
const discriminatorChanged = data.discriminator !== undefined && data.discriminator !== user.discriminator;
const emailChanged = data.email !== undefined && data.email !== user.email;
const newPasswordProvided = data.new_password !== undefined;
if (isUnclaimed) {
return usernameChanged || discriminatorChanged;
}
return usernameChanged || discriminatorChanged || emailTokenProvided || emailChanged || newPasswordProvided;
}
private mapConnectionsToResponse(connections: Array<UserConnectionRow>): Array<ConnectionResponse> {
return connections
.sort((a, b) => a.sort_order - b.sort_order)
.map((connection) => ({
id: connection.connection_id,
type: connection.connection_type,
name: connection.name,
verified: connection.verified,
visibility_flags: connection.visibility_flags,
sort_order: connection.sort_order,
}));
}
}

View File

@@ -0,0 +1,305 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
import {userHasMfa} from '@fluxer/api/src/auth/services/SudoVerificationService';
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {SudoModeRequiredError} from '@fluxer/errors/src/domains/auth/SudoModeRequiredError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {UserUpdateRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import {ms} from 'itty-time';
import {uint8ArrayToBase64} from 'uint8array-extras';
interface UserUpdateMetadata {
invalidateAuthSessions?: boolean;
}
type UserFieldUpdates = Partial<UserRow>;
interface UserAccountSecurityServiceDeps {
userAccountRepository: IUserAccountRepository;
authService: AuthService;
discriminatorService: IDiscriminatorService;
rateLimitService: IRateLimitService;
limitConfigService: LimitConfigService;
}
export class UserAccountSecurityService {
constructor(private readonly deps: UserAccountSecurityServiceDeps) {}
async processSecurityUpdates(params: {
user: User;
data: UserUpdateRequest;
sudoContext?: SudoVerificationResult;
}): Promise<{updates: UserFieldUpdates; metadata: UserUpdateMetadata}> {
const {user, data, sudoContext} = params;
const updates: UserFieldUpdates = {
password_hash: user.passwordHash,
username: user.username,
discriminator: user.discriminator,
global_name: user.isBot ? null : user.globalName,
email: user.email,
};
const metadata: UserUpdateMetadata = {
invalidateAuthSessions: false,
};
const isUnclaimedAccount = user.isUnclaimedAccount();
const identityVerifiedViaSudo = sudoContext?.method === 'mfa' || sudoContext?.method === 'sudo_token';
const identityVerifiedViaPassword = sudoContext?.method === 'password';
const hasMfa = userHasMfa(user);
const rawEmail = data.email?.trim();
const normalizedEmail = rawEmail?.toLowerCase();
const hasPasswordRequiredChanges =
(data.username !== undefined && data.username !== user.username) ||
(data.discriminator !== undefined && data.discriminator !== user.discriminator) ||
(data.email !== undefined && normalizedEmail !== user.email?.toLowerCase()) ||
data.new_password !== undefined;
const requiresVerification = hasPasswordRequiredChanges && !isUnclaimedAccount;
if (requiresVerification && !identityVerifiedViaSudo && !identityVerifiedViaPassword) {
throw new SudoModeRequiredError(hasMfa);
}
if (isUnclaimedAccount && data.new_password) {
updates.password_hash = await this.hashNewPassword(data.new_password);
updates.password_last_changed_at = new Date();
metadata.invalidateAuthSessions = false;
} else if (data.new_password) {
if (!data.password) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
}
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
throw new SudoModeRequiredError(hasMfa);
}
updates.password_hash = await this.hashNewPassword(data.new_password);
updates.password_last_changed_at = new Date();
metadata.invalidateAuthSessions = true;
}
if (data.username !== undefined) {
const {newUsername, newDiscriminator} = await this.updateUsername({
user,
username: data.username,
requestedDiscriminator: data.discriminator,
});
updates.username = newUsername;
updates.discriminator = newDiscriminator;
} else if (data.discriminator !== undefined) {
updates.discriminator = await this.updateDiscriminator({user, discriminator: data.discriminator});
}
if (user.isBot) {
updates.global_name = null;
} else if (data.global_name !== undefined) {
if (data.global_name !== user.globalName) {
getMetricsService().counter({name: 'fluxer.users.display_name_updated'});
}
updates.global_name = data.global_name;
}
if (rawEmail) {
if (normalizedEmail && normalizedEmail !== user.email?.toLowerCase()) {
const existing = await this.deps.userAccountRepository.findByEmail(normalizedEmail);
if (existing && existing.id !== user.id) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE);
}
}
updates.email = rawEmail;
}
return {updates, metadata};
}
async invalidateAndRecreateSessions({
user,
oldAuthSession,
request,
}: {
user: User;
oldAuthSession: AuthSession;
request: Request;
}): Promise<void> {
await this.deps.authService.terminateAllUserSessions(user.id);
const [newToken, newAuthSession] = await this.deps.authService.createAuthSession({user, request});
const oldAuthSessionIdHash = uint8ArrayToBase64(oldAuthSession.sessionIdHash, {urlSafe: true});
await this.deps.authService.dispatchAuthSessionChange({
userId: user.id,
oldAuthSessionIdHash,
newAuthSessionIdHash: uint8ArrayToBase64(newAuthSession.sessionIdHash, {urlSafe: true}),
newToken,
});
}
private async hashNewPassword(newPassword: string): Promise<string> {
if (await this.deps.authService.isPasswordPwned(newPassword)) {
throw InputValidationError.fromCode('new_password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
}
return await this.deps.authService.hashPassword(newPassword);
}
private async updateUsername({
user,
username,
requestedDiscriminator,
}: {
user: User;
username: string;
requestedDiscriminator?: number;
}): Promise<{newUsername: string; newDiscriminator: number}> {
const normalizedRequestedDiscriminator =
requestedDiscriminator == null ? undefined : Number(requestedDiscriminator);
if (
user.username.toLowerCase() === username.toLowerCase() &&
(normalizedRequestedDiscriminator === undefined || normalizedRequestedDiscriminator === user.discriminator)
) {
return {
newUsername: username,
newDiscriminator: user.discriminator,
};
}
const rateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `username_change:${user.id}`,
maxAttempts: 5,
windowMs: ms('1 hour'),
});
if (!rateLimit.allowed) {
const minutes = Math.ceil((rateLimit.retryAfter || 0) / 60);
throw InputValidationError.fromCode('username', ValidationErrorCodes.USERNAME_CHANGED_TOO_MANY_TIMES, {
minutes,
});
}
const ctx = createLimitMatchContext({user});
const hasCustomDiscriminator = resolveLimitSafe(
this.deps.limitConfigService.getConfigSnapshot(),
ctx,
'feature_custom_discriminator',
0,
);
if (
hasCustomDiscriminator === 0 &&
user.username === username &&
(normalizedRequestedDiscriminator === undefined || normalizedRequestedDiscriminator === user.discriminator)
) {
return {
newUsername: user.username,
newDiscriminator: user.discriminator,
};
}
if (hasCustomDiscriminator === 0) {
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
username,
requestedDiscriminator: undefined,
user,
});
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
throw InputValidationError.fromCode(
'username',
ValidationErrorCodes.TOO_MANY_USERS_WITH_USERNAME_TRY_DIFFERENT,
);
}
return {
newUsername: username,
newDiscriminator: discriminatorResult.discriminator,
};
}
const discriminatorToUse = normalizedRequestedDiscriminator ?? user.discriminator;
if (discriminatorToUse === 0 && user.premiumType !== UserPremiumTypes.LIFETIME) {
throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.VISIONARY_REQUIRED_FOR_DISCRIMINATOR);
}
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
username,
requestedDiscriminator: discriminatorToUse,
user,
});
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
throw InputValidationError.fromCode(
'username',
discriminatorToUse !== undefined
? ValidationErrorCodes.TAG_ALREADY_TAKEN
: ValidationErrorCodes.TOO_MANY_USERS_WITH_USERNAME_TRY_DIFFERENT,
);
}
return {
newUsername: username,
newDiscriminator: discriminatorResult.discriminator,
};
}
private async updateDiscriminator({user, discriminator}: {user: User; discriminator: number}): Promise<number> {
const ctx = createLimitMatchContext({user});
const hasCustomDiscriminator = resolveLimitSafe(
this.deps.limitConfigService.getConfigSnapshot(),
ctx,
'feature_custom_discriminator',
0,
);
if (hasCustomDiscriminator === 0) {
throw InputValidationError.fromCode(
'discriminator',
ValidationErrorCodes.CHANGING_DISCRIMINATOR_REQUIRES_PREMIUM,
);
}
if (discriminator === 0 && user.premiumType !== UserPremiumTypes.LIFETIME) {
throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.VISIONARY_REQUIRED_FOR_DISCRIMINATOR);
}
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
username: user.username,
requestedDiscriminator: discriminator,
user,
});
if (!discriminatorResult.available) {
throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.TAG_ALREADY_TAKEN);
}
return discriminator;
}
}

View File

@@ -0,0 +1,353 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {IConnectionRepository} from '@fluxer/api/src/connection/IConnectionRepository';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
import {GuildMemberSearchIndexService} from '@fluxer/api/src/guild/services/member/GuildMemberSearchIndexService';
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
import type {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAccountDeletionQueueService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import {Logger} from '@fluxer/api/src/Logger';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {User} from '@fluxer/api/src/models/User';
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
import type {PackService} from '@fluxer/api/src/pack/PackService';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
import type {IUserSettingsRepository} from '@fluxer/api/src/user/repositories/IUserSettingsRepository';
import {UserAccountLifecycleService} from '@fluxer/api/src/user/services/UserAccountLifecycleService';
import {UserAccountLookupService} from '@fluxer/api/src/user/services/UserAccountLookupService';
import {UserAccountNotesService} from '@fluxer/api/src/user/services/UserAccountNotesService';
import {UserAccountProfileService} from '@fluxer/api/src/user/services/UserAccountProfileService';
import {UserAccountSecurityService} from '@fluxer/api/src/user/services/UserAccountSecurityService';
import {UserAccountSettingsService} from '@fluxer/api/src/user/services/UserAccountSettingsService';
import {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
import {createPremiumClearPatch} from '@fluxer/api/src/user/UserHelpers';
import {hasPartialUserFieldsChanged} from '@fluxer/api/src/user/UserMappers';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {
UserGuildSettingsUpdateRequest,
UserSettingsUpdateRequest,
UserUpdateRequest,
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
interface UpdateUserParams {
user: User;
oldAuthSession: AuthSession;
data: UserUpdateRequest;
request: Request;
sudoContext?: SudoVerificationResult;
emailVerifiedViaToken?: boolean;
}
export class UserAccountService {
private readonly lookupService: UserAccountLookupService;
private readonly profileService: UserAccountProfileService;
private readonly securityService: UserAccountSecurityService;
private readonly settingsService: UserAccountSettingsService;
private readonly notesService: UserAccountNotesService;
private readonly lifecycleService: UserAccountLifecycleService;
private readonly updatePropagator: UserAccountUpdatePropagator;
private readonly guildRepository: IGuildRepositoryAggregate;
private readonly searchIndexService: GuildMemberSearchIndexService;
constructor(
private readonly userAccountRepository: IUserAccountRepository,
userSettingsRepository: IUserSettingsRepository,
userRelationshipRepository: IUserRelationshipRepository,
userChannelRepository: IUserChannelRepository,
authService: AuthService,
userCacheService: UserCacheService,
guildService: GuildService,
gatewayService: IGatewayService,
entityAssetService: EntityAssetService,
mediaService: IMediaService,
packService: PackService,
emailService: IEmailService,
rateLimitService: IRateLimitService,
guildRepository: IGuildRepositoryAggregate,
discriminatorService: IDiscriminatorService,
kvDeletionQueue: KVAccountDeletionQueueService,
private readonly contactChangeLogService: UserContactChangeLogService,
connectionRepository: IConnectionRepository,
readonly limitConfigService: LimitConfigService,
) {
this.guildRepository = guildRepository;
this.searchIndexService = new GuildMemberSearchIndexService();
this.updatePropagator = new UserAccountUpdatePropagator({
userCacheService,
gatewayService,
mediaService,
userRepository: userAccountRepository,
});
this.lookupService = new UserAccountLookupService({
userAccountRepository,
userRelationshipRepository,
userChannelRepository,
guildRepository,
guildService,
discriminatorService,
connectionRepository,
});
this.profileService = new UserAccountProfileService({
userAccountRepository,
guildRepository,
entityAssetService,
rateLimitService,
updatePropagator: this.updatePropagator,
limitConfigService,
});
this.securityService = new UserAccountSecurityService({
userAccountRepository,
authService,
discriminatorService,
rateLimitService,
limitConfigService,
});
this.settingsService = new UserAccountSettingsService({
userAccountRepository,
userSettingsRepository,
updatePropagator: this.updatePropagator,
guildRepository,
packService,
limitConfigService,
});
this.notesService = new UserAccountNotesService({
userAccountRepository,
userRelationshipRepository,
updatePropagator: this.updatePropagator,
});
this.lifecycleService = new UserAccountLifecycleService({
userAccountRepository,
guildRepository,
authService,
emailService,
updatePropagator: this.updatePropagator,
kvDeletionQueue,
});
}
async findUnique(userId: UserID): Promise<User | null> {
return this.lookupService.findUnique(userId);
}
async findUniqueAssert(userId: UserID): Promise<User> {
return this.lookupService.findUniqueAssert(userId);
}
async getUserProfile(params: {
userId: UserID;
targetId: UserID;
guildId?: GuildID;
withMutualFriends?: boolean;
withMutualGuilds?: boolean;
requestCache: RequestCache;
}) {
return this.lookupService.getUserProfile(params);
}
async generateUniqueDiscriminator(username: string): Promise<number> {
return this.lookupService.generateUniqueDiscriminator(username);
}
async checkUsernameDiscriminatorAvailability(params: {username: string; discriminator: number}): Promise<boolean> {
return this.lookupService.checkUsernameDiscriminatorAvailability(params);
}
async update(params: UpdateUserParams): Promise<User> {
const {user, oldAuthSession, data, request, sudoContext, emailVerifiedViaToken = false} = params;
const profileResult = await this.profileService.processProfileUpdates({user, data});
const securityResult = await this.securityService.processSecurityUpdates({user, data, sudoContext});
const updates = {
...securityResult.updates,
...profileResult.updates,
};
const metadata = {
...securityResult.metadata,
...profileResult.metadata,
};
const emailChanged = data.email !== undefined;
if (emailChanged) {
updates.email_verified = !!emailVerifiedViaToken;
}
let updatedUser: User;
try {
updatedUser = await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
} catch (error) {
await this.profileService.rollbackAssetChanges(profileResult);
Logger.error({error, userId: user.id}, 'User update failed, rolled back asset uploads');
throw error;
}
await this.contactChangeLogService.recordDiff({
oldUser: user,
newUser: updatedUser,
reason: 'user_requested',
actorUserId: user.id,
});
await this.profileService.commitAssetChanges(profileResult).catch((error) => {
Logger.error({error, userId: user.id}, 'Failed to commit asset changes after successful DB update');
});
await this.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.updatePropagator.updateUserCache(updatedUser);
}
const nameChanged =
user.username !== updatedUser.username ||
user.discriminator !== updatedUser.discriminator ||
user.globalName !== updatedUser.globalName;
if (nameChanged) {
void this.reindexGuildMembersForUser(updatedUser);
}
if (metadata.invalidateAuthSessions) {
await this.securityService.invalidateAndRecreateSessions({user, oldAuthSession, request});
}
return updatedUser;
}
private async reindexGuildMembersForUser(updatedUser: User): Promise<void> {
try {
const guildIds = await this.userAccountRepository.getUserGuildIds(updatedUser.id);
for (const guildId of guildIds) {
const guild = await this.guildRepository.findUnique(guildId);
if (!guild?.membersIndexedAt) {
continue;
}
const member = await this.guildRepository.getMember(guildId, updatedUser.id);
if (member) {
void this.searchIndexService.updateMember(member, updatedUser);
}
}
} catch (error) {
Logger.error({userId: updatedUser.id.toString(), error}, 'Failed to reindex guild members after user update');
}
}
async findSettings(userId: UserID): Promise<UserSettings> {
return this.settingsService.findSettings(userId);
}
async updateSettings(params: {userId: UserID; data: UserSettingsUpdateRequest}): Promise<UserSettings> {
return this.settingsService.updateSettings(params);
}
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
return this.settingsService.findGuildSettings(userId, guildId);
}
async updateGuildSettings(params: {
userId: UserID;
guildId: GuildID | null;
data: UserGuildSettingsUpdateRequest;
}): Promise<UserGuildSettings> {
return this.settingsService.updateGuildSettings(params);
}
async getUserNote(params: {userId: UserID; targetId: UserID}): Promise<{note: string} | null> {
return this.notesService.getUserNote(params);
}
async getUserNotes(userId: UserID): Promise<Record<string, string>> {
return this.notesService.getUserNotes(userId);
}
async setUserNote(params: {userId: UserID; targetId: UserID; note: string | null}): Promise<void> {
return this.notesService.setUserNote(params);
}
async selfDisable(userId: UserID): Promise<void> {
return this.lifecycleService.selfDisable(userId);
}
async selfDelete(userId: UserID): Promise<void> {
return this.lifecycleService.selfDelete(userId);
}
async resetCurrentUserPremiumState(user: User): Promise<void> {
const updates = {
...createPremiumClearPatch(),
premium_lifetime_sequence: null,
stripe_subscription_id: null,
stripe_customer_id: null,
has_ever_purchased: null,
first_refund_at: null,
gift_inventory_server_seq: null,
gift_inventory_client_seq: null,
flags: user.flags & ~UserFlags.PREMIUM_ENABLED_OVERRIDE,
};
const updatedUser = await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
await this.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.updatePropagator.updateUserCache(updatedUser);
}
}
async dispatchUserUpdate(user: User): Promise<void> {
return this.updatePropagator.dispatchUserUpdate(user);
}
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
return this.updatePropagator.dispatchUserSettingsUpdate({userId, settings});
}
async dispatchUserGuildSettingsUpdate({
userId,
settings,
}: {
userId: UserID;
settings: UserGuildSettings;
}): Promise<void> {
return this.updatePropagator.dispatchUserGuildSettingsUpdate({userId, settings});
}
async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise<void> {
return this.updatePropagator.dispatchUserNoteUpdate(params);
}
}

View File

@@ -0,0 +1,339 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {type ChannelID, createChannelID, createGuildID, type GuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
import type {ChannelOverride, UserGuildSettingsRow} from '@fluxer/api/src/database/types/UserTypes';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
import type {PackService} from '@fluxer/api/src/pack/PackService';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserSettingsRepository} from '@fluxer/api/src/user/repositories/IUserSettingsRepository';
import {CustomStatusValidator} from '@fluxer/api/src/user/services/CustomStatusValidator';
import type {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
import {
DEFAULT_GUILD_FOLDER_ICON,
FriendSourceFlags,
GroupDmAddPermissionFlags,
IncomingCallFlags,
UNCATEGORIZED_FOLDER_ID,
UserNotificationSettings,
} from '@fluxer/constants/src/UserConstants';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {ValidationError} from '@fluxer/errors/src/ValidationError';
import type {
UserGuildSettingsUpdateRequest,
UserSettingsUpdateRequest,
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
interface UserAccountSettingsServiceDeps {
userAccountRepository: IUserAccountRepository;
userSettingsRepository: IUserSettingsRepository;
updatePropagator: UserAccountUpdatePropagator;
guildRepository: IGuildRepositoryAggregate;
packService: PackService;
limitConfigService: LimitConfigService;
}
export class UserAccountSettingsService {
private readonly customStatusValidator: CustomStatusValidator;
constructor(private readonly deps: UserAccountSettingsServiceDeps) {
this.customStatusValidator = new CustomStatusValidator(
this.deps.userAccountRepository,
this.deps.guildRepository,
this.deps.packService,
this.deps.limitConfigService,
);
}
async findSettings(userId: UserID): Promise<UserSettings> {
const userSettings = await this.deps.userSettingsRepository.findSettings(userId);
if (!userSettings) throw new UnknownUserError();
return userSettings;
}
async updateSettings(params: {userId: UserID; data: UserSettingsUpdateRequest}): Promise<UserSettings> {
const {userId, data} = params;
const currentSettings = await this.deps.userSettingsRepository.findSettings(userId);
if (!currentSettings) {
throw new UnknownUserError();
}
const updatedRowData = {...currentSettings.toRow(), user_id: userId};
const localeChanged = data.locale !== undefined && data.locale !== currentSettings.locale;
if (data.status !== undefined) updatedRowData.status = data.status;
if (data.status_resets_at !== undefined) updatedRowData.status_resets_at = data.status_resets_at;
if (data.status_resets_to !== undefined) updatedRowData.status_resets_to = data.status_resets_to;
if (data.theme !== undefined) {
if (data.theme !== currentSettings.theme) {
getMetricsService().counter({
name: 'fluxer.users.theme_changed',
dimensions: {
new_theme: data.theme,
old_theme: currentSettings.theme,
},
});
}
updatedRowData.theme = data.theme;
}
if (data.locale !== undefined) updatedRowData.locale = data.locale;
if (data.custom_status !== undefined) {
if (data.custom_status === null) {
updatedRowData.custom_status = null;
} else {
const validated = await this.customStatusValidator.validate(userId, data.custom_status);
updatedRowData.custom_status = {
text: validated.text,
expires_at: validated.expiresAt,
emoji_id: validated.emojiId,
emoji_name: validated.emojiName,
emoji_animated: validated.emojiAnimated,
};
}
}
if (data.flags !== undefined) updatedRowData.friend_source_flags = data.flags;
if (data.restricted_guilds !== undefined) {
updatedRowData.restricted_guilds = data.restricted_guilds
? new Set(data.restricted_guilds.map(createGuildID))
: null;
}
if (data.bot_restricted_guilds !== undefined) {
updatedRowData.bot_restricted_guilds = data.bot_restricted_guilds
? new Set(data.bot_restricted_guilds.map(createGuildID))
: null;
}
if (data.default_guilds_restricted !== undefined) {
updatedRowData.default_guilds_restricted = data.default_guilds_restricted;
}
if (data.bot_default_guilds_restricted !== undefined) {
updatedRowData.bot_default_guilds_restricted = data.bot_default_guilds_restricted;
}
if (data.inline_attachment_media !== undefined) {
updatedRowData.inline_attachment_media = data.inline_attachment_media;
}
if (data.inline_embed_media !== undefined) updatedRowData.inline_embed_media = data.inline_embed_media;
if (data.gif_auto_play !== undefined) updatedRowData.gif_auto_play = data.gif_auto_play;
if (data.render_embeds !== undefined) updatedRowData.render_embeds = data.render_embeds;
if (data.render_reactions !== undefined) updatedRowData.render_reactions = data.render_reactions;
if (data.animate_emoji !== undefined) updatedRowData.animate_emoji = data.animate_emoji;
if (data.animate_stickers !== undefined) updatedRowData.animate_stickers = data.animate_stickers;
if (data.render_spoilers !== undefined) updatedRowData.render_spoilers = data.render_spoilers;
if (data.message_display_compact !== undefined) {
updatedRowData.message_display_compact = data.message_display_compact;
}
if (data.friend_source_flags !== undefined) {
updatedRowData.friend_source_flags = this.normalizeFriendSourceFlags(data.friend_source_flags);
}
if (data.incoming_call_flags !== undefined) {
updatedRowData.incoming_call_flags = this.normalizeIncomingCallFlags(data.incoming_call_flags);
}
if (data.group_dm_add_permission_flags !== undefined) {
updatedRowData.group_dm_add_permission_flags = this.normalizeGroupDmAddPermissionFlags(
data.group_dm_add_permission_flags,
);
}
if (data.guild_folders !== undefined) {
const mappedFolders = data.guild_folders.map((folder) => ({
folder_id: folder.id,
name: folder.name ?? null,
color: folder.color ?? 0x000000,
flags: folder.flags ?? 0,
icon: folder.icon ?? DEFAULT_GUILD_FOLDER_ICON,
guild_ids: folder.guild_ids.map(createGuildID),
}));
const hasUncategorized = mappedFolders.some((folder) => folder.folder_id === UNCATEGORIZED_FOLDER_ID);
if (!hasUncategorized) {
mappedFolders.unshift({
folder_id: UNCATEGORIZED_FOLDER_ID,
name: null,
color: 0x000000,
flags: 0,
icon: DEFAULT_GUILD_FOLDER_ICON,
guild_ids: [],
});
}
updatedRowData.guild_folders = mappedFolders;
}
if (data.afk_timeout !== undefined) updatedRowData.afk_timeout = data.afk_timeout;
if (data.time_format !== undefined) updatedRowData.time_format = data.time_format;
if (data.developer_mode !== undefined) updatedRowData.developer_mode = data.developer_mode;
if (data.trusted_domains !== undefined) {
const domainsSet = new Set(data.trusted_domains);
if (domainsSet.has('*') && domainsSet.size > 1) {
throw ValidationError.fromField(
'trusted_domains',
'INVALID_TRUSTED_DOMAINS',
'Cannot combine wildcard (*) with specific domains',
);
}
updatedRowData.trusted_domains = domainsSet.size > 0 ? domainsSet : null;
}
if (data.default_hide_muted_channels !== undefined) {
updatedRowData.default_hide_muted_channels = data.default_hide_muted_channels;
}
await this.deps.userSettingsRepository.upsertSettings(updatedRowData);
const updatedSettings = await this.findSettings(userId);
await this.deps.updatePropagator.dispatchUserSettingsUpdate({userId, settings: updatedSettings});
if (localeChanged) {
const user = await this.deps.userAccountRepository.findUnique(userId);
if (user) {
const updatedUser = await this.deps.userAccountRepository.patchUpsert(
userId,
{locale: data.locale},
user.toRow(),
);
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
}
}
return updatedSettings;
}
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
return await this.deps.userSettingsRepository.findGuildSettings(userId, guildId);
}
async updateGuildSettings(params: {
userId: UserID;
guildId: GuildID | null;
data: UserGuildSettingsUpdateRequest;
}): Promise<UserGuildSettings> {
const {userId, guildId, data} = params;
const currentSettings = await this.deps.userSettingsRepository.findGuildSettings(userId, guildId);
const resolvedGuildId = guildId ?? createGuildID(0n);
const baseRow: UserGuildSettingsRow = currentSettings
? {
...currentSettings.toRow(),
user_id: userId,
guild_id: resolvedGuildId,
}
: {
user_id: userId,
guild_id: resolvedGuildId,
message_notifications: UserNotificationSettings.INHERIT,
muted: false,
mute_config: null,
mobile_push: false,
suppress_everyone: false,
suppress_roles: false,
hide_muted_channels: false,
channel_overrides: null,
version: 1,
};
const updatedRowData: UserGuildSettingsRow = {...baseRow};
if (data.message_notifications !== undefined) updatedRowData.message_notifications = data.message_notifications;
if (data.muted !== undefined) updatedRowData.muted = data.muted;
if (data.mute_config !== undefined) {
updatedRowData.mute_config = data.mute_config
? {
end_time: data.mute_config.end_time ?? null,
selected_time_window: data.mute_config.selected_time_window,
}
: null;
}
if (data.mobile_push !== undefined) updatedRowData.mobile_push = data.mobile_push;
if (data.suppress_everyone !== undefined) updatedRowData.suppress_everyone = data.suppress_everyone;
if (data.suppress_roles !== undefined) updatedRowData.suppress_roles = data.suppress_roles;
if (data.hide_muted_channels !== undefined) updatedRowData.hide_muted_channels = data.hide_muted_channels;
if (data.channel_overrides !== undefined) {
if (data.channel_overrides) {
const channelOverrides = new Map<ChannelID, ChannelOverride>();
for (const [channelIdStr, override] of Object.entries(data.channel_overrides)) {
const channelId = createChannelID(BigInt(channelIdStr));
channelOverrides.set(channelId, {
collapsed: override.collapsed,
message_notifications: override.message_notifications,
muted: override.muted,
mute_config: override.mute_config
? {
end_time: override.mute_config.end_time ?? null,
selected_time_window: override.mute_config.selected_time_window,
}
: null,
});
}
updatedRowData.channel_overrides = channelOverrides.size > 0 ? channelOverrides : null;
} else {
updatedRowData.channel_overrides = null;
}
}
const updatedSettings = await this.deps.userSettingsRepository.upsertGuildSettings(updatedRowData);
await this.deps.updatePropagator.dispatchUserGuildSettingsUpdate({userId, settings: updatedSettings});
return updatedSettings;
}
private normalizeFriendSourceFlags(flags: number): number {
let normalizedFlags = flags;
if ((normalizedFlags & FriendSourceFlags.NO_RELATION) === FriendSourceFlags.NO_RELATION) {
const hasMutualFriends =
(normalizedFlags & FriendSourceFlags.MUTUAL_FRIENDS) === FriendSourceFlags.MUTUAL_FRIENDS;
const hasMutualGuilds = (normalizedFlags & FriendSourceFlags.MUTUAL_GUILDS) === FriendSourceFlags.MUTUAL_GUILDS;
if (!hasMutualFriends || !hasMutualGuilds) {
normalizedFlags &= ~FriendSourceFlags.NO_RELATION;
}
}
return normalizedFlags;
}
private normalizeIncomingCallFlags(flags: number): number {
let normalizedFlags = flags;
const modifierFlags = flags & IncomingCallFlags.SILENT_EVERYONE;
if ((normalizedFlags & IncomingCallFlags.FRIENDS_ONLY) === IncomingCallFlags.FRIENDS_ONLY) {
normalizedFlags = IncomingCallFlags.FRIENDS_ONLY | modifierFlags;
}
if ((normalizedFlags & IncomingCallFlags.NOBODY) === IncomingCallFlags.NOBODY) {
normalizedFlags = IncomingCallFlags.NOBODY | modifierFlags;
}
return normalizedFlags;
}
private normalizeGroupDmAddPermissionFlags(flags: number): number {
let normalizedFlags = flags;
if ((normalizedFlags & GroupDmAddPermissionFlags.FRIENDS_ONLY) === GroupDmAddPermissionFlags.FRIENDS_ONLY) {
normalizedFlags = GroupDmAddPermissionFlags.FRIENDS_ONLY;
}
if ((normalizedFlags & GroupDmAddPermissionFlags.NOBODY) === GroupDmAddPermissionFlags.NOBODY) {
normalizedFlags = GroupDmAddPermissionFlags.NOBODY;
}
if ((normalizedFlags & GroupDmAddPermissionFlags.EVERYONE) === GroupDmAddPermissionFlags.EVERYONE) {
normalizedFlags = GroupDmAddPermissionFlags.EVERYONE;
}
return normalizedFlags;
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import {BaseUserUpdatePropagator} from '@fluxer/api/src/user/services/BaseUserUpdatePropagator';
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '@fluxer/api/src/user/UserMappers';
interface UserAccountUpdatePropagatorDeps {
userCacheService: UserCacheService;
gatewayService: IGatewayService;
mediaService: IMediaService;
userRepository: IUserAccountRepository;
}
export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator {
constructor(private readonly deps: UserAccountUpdatePropagatorDeps) {
super({
userCacheService: deps.userCacheService,
gatewayService: deps.gatewayService,
});
}
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
const guildIds = await this.deps.userRepository.getUserGuildIds(userId);
await this.deps.gatewayService.dispatchPresence({
userId,
event: 'USER_SETTINGS_UPDATE',
data: mapUserSettingsToResponse({settings, memberGuildIds: guildIds}),
});
}
async dispatchUserGuildSettingsUpdate({
userId,
settings,
}: {
userId: UserID;
settings: UserGuildSettings;
}): Promise<void> {
await this.deps.gatewayService.dispatchPresence({
userId,
event: 'USER_GUILD_SETTINGS_UPDATE',
data: mapUserGuildSettingsToResponse(settings),
});
}
async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise<void> {
const {userId, targetId, note} = params;
await this.deps.gatewayService.dispatchPresence({
userId,
event: 'USER_NOTE_UPDATE',
data: {id: targetId.toString(), note},
});
}
}

View File

@@ -0,0 +1,204 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {AuthMfaService} from '@fluxer/api/src/auth/services/AuthMfaService';
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import type {UserService} from '@fluxer/api/src/user/services/UserService';
import type {
DisableTotpRequest,
EnableMfaTotpRequest,
MfaBackupCodesRequest,
MfaBackupCodesResponse,
PhoneAddRequest,
PhoneSendVerificationRequest,
PhoneVerifyRequest,
PhoneVerifyResponse,
SudoMfaMethodsResponse,
WebAuthnChallengeResponse,
WebAuthnCredentialListResponse,
WebAuthnCredentialUpdateRequest,
WebAuthnRegisterRequest,
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
interface UserAuthWithSudoRequest<T> {
user: User;
data: T;
sudoContext: SudoVerificationResult;
}
interface UserAuthRequest<T> {
user: User;
data: T;
}
interface UserAuthPhoneTokenRequest {
user: User;
data: PhoneAddRequest;
}
interface UserAuthWebAuthnUpdateRequest {
user: User;
credentialId: string;
data: WebAuthnCredentialUpdateRequest;
}
interface UserAuthWebAuthnRegisterRequest {
user: User;
data: WebAuthnRegisterRequest;
}
interface UserAuthWebAuthnDeleteRequest {
user: User;
credentialId: string;
}
export class UserAuthRequestService {
constructor(
private authService: AuthService,
private authMfaService: AuthMfaService,
private userService: UserService,
private userRepository: IUserRepository,
) {}
async enableTotp({
user,
data,
sudoContext,
}: UserAuthWithSudoRequest<EnableMfaTotpRequest>): Promise<MfaBackupCodesResponse> {
const backupCodes = await this.userService.enableMfaTotp({
user,
secret: data.secret,
code: data.code,
sudoContext,
});
return this.toBackupCodesResponse(backupCodes);
}
async disableTotp({user, data, sudoContext}: UserAuthWithSudoRequest<DisableTotpRequest>): Promise<void> {
await this.userService.disableMfaTotp({
user,
code: data.code,
sudoContext,
password: data.password,
});
}
async getBackupCodes({
user,
data,
sudoContext,
}: UserAuthWithSudoRequest<MfaBackupCodesRequest>): Promise<MfaBackupCodesResponse> {
const backupCodes = await this.userService.getMfaBackupCodes({
user,
regenerate: data.regenerate,
sudoContext,
password: data.password,
});
return this.toBackupCodesResponse(backupCodes);
}
async sendPhoneVerificationCode({user, data}: UserAuthRequest<PhoneSendVerificationRequest>): Promise<void> {
await this.authService.sendPhoneVerificationCode(data.phone, user.id);
}
async verifyPhoneCode({user, data}: UserAuthRequest<PhoneVerifyRequest>): Promise<PhoneVerifyResponse> {
const phoneToken = await this.authService.verifyPhoneCode(data.phone, data.code, user.id);
return {phone_token: phoneToken};
}
async addPhoneToAccount({user, data}: UserAuthPhoneTokenRequest): Promise<void> {
await this.authService.addPhoneToAccount(user.id, data.phone_token);
}
async removePhoneFromAccount(user: User): Promise<void> {
await this.authService.removePhoneFromAccount(user.id);
}
async enableSmsMfa(user: User): Promise<void> {
await this.authService.enableSmsMfa(user.id);
}
async disableSmsMfa(user: User): Promise<void> {
await this.authService.disableSmsMfa(user.id);
}
async forgetAuthorizedIps(user: User): Promise<void> {
await this.userRepository.deleteAllAuthorizedIps(user.id);
}
async listWebAuthnCredentials(user: User): Promise<WebAuthnCredentialListResponse> {
const credentials = await this.userRepository.listWebAuthnCredentials(user.id);
return credentials.map((cred) => ({
id: cred.credentialId,
name: cred.name,
created_at: cred.createdAt.toISOString(),
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
}));
}
async generateWebAuthnRegistrationOptions(user: User): Promise<WebAuthnChallengeResponse> {
const options = await this.authService.generateWebAuthnRegistrationOptions(user.id);
return this.toWebAuthnChallengeResponse(options);
}
async registerWebAuthnCredential({user, data}: UserAuthWebAuthnRegisterRequest): Promise<void> {
await this.authService.verifyWebAuthnRegistration(user.id, data.response, data.challenge, data.name);
}
async renameWebAuthnCredential({user, credentialId, data}: UserAuthWebAuthnUpdateRequest): Promise<void> {
await this.authService.renameWebAuthnCredential(user.id, credentialId, data.name);
}
async deleteWebAuthnCredential({user, credentialId}: UserAuthWebAuthnDeleteRequest): Promise<void> {
await this.authService.deleteWebAuthnCredential(user.id, credentialId);
}
async listSudoMfaMethods(user: User): Promise<SudoMfaMethodsResponse> {
return this.authMfaService.getAvailableMfaMethods(user.id);
}
async sendSudoSmsCode(user: User): Promise<void> {
await this.authService.sendSmsMfaCode(user.id);
}
async getSudoWebAuthnOptions(user: User): Promise<WebAuthnChallengeResponse> {
const options = await this.authMfaService.generateWebAuthnOptionsForSudo(user.id);
return this.toWebAuthnChallengeResponse(options);
}
private toWebAuthnChallengeResponse(options: {challenge: string}): WebAuthnChallengeResponse {
const response: Record<string, unknown> & {challenge: string} = {
...options,
challenge: options.challenge,
};
return response;
}
private toBackupCodesResponse(backupCodes: Array<{code: string; consumed: boolean}>): MfaBackupCodesResponse {
return {
backup_codes: backupCodes.map((code) => ({
code: code.code,
consumed: code.consumed,
})),
};
}
}

View File

@@ -0,0 +1,207 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
import {userHasMfa} from '@fluxer/api/src/auth/services/SudoVerificationService';
import {createEmailVerificationToken} from '@fluxer/api/src/BrandedTypes';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
import type {User} from '@fluxer/api/src/models/User';
import type {BotMfaMirrorService} from '@fluxer/api/src/oauth/BotMfaMirrorService';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserAuthRepository} from '@fluxer/api/src/user/repositories/IUserAuthRepository';
import {mapUserToPrivateResponse} from '@fluxer/api/src/user/UserMappers';
import * as RandomUtils from '@fluxer/api/src/utils/RandomUtils';
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import {MfaNotDisabledError} from '@fluxer/errors/src/domains/auth/MfaNotDisabledError';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import {SudoModeRequiredError} from '@fluxer/errors/src/domains/auth/SudoModeRequiredError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
export class UserAuthService {
constructor(
private userAccountRepository: IUserAccountRepository,
private userAuthRepository: IUserAuthRepository,
private authService: AuthService,
private emailService: IEmailService,
private gatewayService: IGatewayService,
private botMfaMirrorService?: BotMfaMirrorService,
) {}
async enableMfaTotp(params: {
user: User;
secret: string;
code: string;
sudoContext: SudoVerificationResult;
}): Promise<Array<MfaBackupCode>> {
const {user, secret, code, sudoContext} = params;
const identityVerifiedViaSudo = sudoContext.method === 'mfa' || sudoContext.method === 'sudo_token';
const identityVerifiedViaPassword = sudoContext.method === 'password';
const hasMfa = userHasMfa(user);
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
throw new SudoModeRequiredError(hasMfa);
}
if (user.totpSecret) throw new MfaNotDisabledError();
const userId = user.id;
if (!(await this.authService.verifyMfaCode({userId: user.id, mfaSecret: secret, code}))) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
authenticatorTypes.add(UserAuthenticatorTypes.TOTP);
const updatedUser = await this.userAccountRepository.patchUpsert(
userId,
{
totp_secret: secret,
authenticator_types: authenticatorTypes,
},
user.toRow(),
);
const newBackupCodes = this.authService.generateBackupCodes();
const mfaBackupCodes = await this.userAuthRepository.createMfaBackupCodes(userId, newBackupCodes);
await this.dispatchUserUpdate(updatedUser);
if (updatedUser) {
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
}
return mfaBackupCodes;
}
async disableMfaTotp(params: {user: User; code: string; sudoContext: SudoVerificationResult}): Promise<void> {
const {user, code, sudoContext} = params;
if (!user.totpSecret) throw new MfaNotEnabledError();
const identityVerifiedViaSudo = sudoContext.method === 'mfa' || sudoContext.method === 'sudo_token';
const identityVerifiedViaPassword = sudoContext.method === 'password';
const hasMfa = userHasMfa(user);
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
throw new SudoModeRequiredError(hasMfa);
}
if (
!(await this.authService.verifyMfaCode({
userId: user.id,
mfaSecret: user.totpSecret,
code,
allowBackup: true,
}))
) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
const userId = user.id;
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
authenticatorTypes.delete(UserAuthenticatorTypes.TOTP);
const hasSms = authenticatorTypes.has(UserAuthenticatorTypes.SMS);
if (hasSms) {
authenticatorTypes.delete(UserAuthenticatorTypes.SMS);
}
const updatedUser = await this.userAccountRepository.patchUpsert(
userId,
{
totp_secret: null,
authenticator_types: authenticatorTypes,
},
user.toRow(),
);
await this.userAuthRepository.clearMfaBackupCodes(userId);
await this.dispatchUserUpdate(updatedUser);
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
}
async getMfaBackupCodes(params: {
user: User;
regenerate: boolean;
sudoContext: SudoVerificationResult;
}): Promise<Array<MfaBackupCode>> {
const {user, regenerate, sudoContext} = params;
const identityVerifiedViaSudo = sudoContext.method === 'mfa' || sudoContext.method === 'sudo_token';
const identityVerifiedViaPassword = sudoContext.method === 'password';
const hasMfa = userHasMfa(user);
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
throw new SudoModeRequiredError(hasMfa);
}
if (regenerate) {
return this.regenerateMfaBackupCodes(user);
}
return await this.userAuthRepository.listMfaBackupCodes(user.id);
}
async regenerateMfaBackupCodes(user: User): Promise<Array<MfaBackupCode>> {
const userId = user.id;
const newBackupCodes = this.authService.generateBackupCodes();
await this.userAuthRepository.clearMfaBackupCodes(userId);
return await this.userAuthRepository.createMfaBackupCodes(userId, newBackupCodes);
}
async verifyEmail(token: string): Promise<boolean> {
const emailToken = await this.userAuthRepository.getEmailVerificationToken(token);
if (!emailToken) {
return false;
}
const user = await this.userAccountRepository.findUnique(emailToken.userId);
if (!user) {
return false;
}
const updatedUser = await this.userAccountRepository.patchUpsert(
emailToken.userId,
{
email: emailToken.email,
email_verified: true,
},
user.toRow(),
);
await this.userAuthRepository.deleteEmailVerificationToken(token);
await this.dispatchUserUpdate(updatedUser);
return true;
}
async resendVerificationEmail(user: User): Promise<boolean> {
if (user.emailVerified) {
return true;
}
const email = user.email;
if (!email) {
return false;
}
const verificationToken = createEmailVerificationToken(RandomUtils.randomString(64));
await this.userAuthRepository.createEmailVerificationToken({
token_: verificationToken,
user_id: user.id,
email,
});
await this.emailService.sendEmailVerification(email, user.username, verificationToken, user.locale);
return true;
}
async dispatchUserUpdate(user: User): Promise<void> {
await this.gatewayService.dispatchPresence({
userId: user.id,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(user),
});
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, UserID} from '@fluxer/api/src/BrandedTypes';
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {UserService} from '@fluxer/api/src/user/services/UserService';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
interface UserChannelListParams {
userId: UserID;
requestCache: RequestCache;
}
interface UserChannelCreateParams {
userId: UserID;
data: CreatePrivateChannelRequest;
requestCache: RequestCache;
}
interface UserChannelPinParams {
userId: UserID;
channelId: ChannelID;
}
export class UserChannelRequestService {
constructor(
private readonly userService: UserService,
private readonly userCacheService: UserCacheService,
) {}
async listPrivateChannels(params: UserChannelListParams): Promise<Array<ChannelResponse>> {
const channels = await this.userService.getPrivateChannels(params.userId);
return Promise.all(
channels.map((channel) =>
mapChannelToResponse({
channel,
currentUserId: params.userId,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
}),
),
);
}
async createPrivateChannel(params: UserChannelCreateParams): Promise<ChannelResponse> {
const channel = await this.userService.createOrOpenDMChannel({
userId: params.userId,
data: params.data,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
return mapChannelToResponse({
channel,
currentUserId: params.userId,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
}
async pinChannel(params: UserChannelPinParams): Promise<void> {
await this.userService.pinDmChannel({userId: params.userId, channelId: params.channelId});
}
async unpinChannel(params: UserChannelPinParams): Promise<void> {
await this.userService.unpinDmChannel({userId: params.userId, channelId: params.channelId});
}
}

View File

@@ -0,0 +1,547 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, UserID} from '@fluxer/api/src/BrandedTypes';
import {createChannelID, createMessageID, createUserID} from '@fluxer/api/src/BrandedTypes';
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
import {dispatchMessageCreate} from '@fluxer/api/src/channel/services/group_dm/GroupDmHelpers';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {Channel} from '@fluxer/api/src/models/Channel';
import type {Message} from '@fluxer/api/src/models/Message';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
import type {UserPermissionUtils} from '@fluxer/api/src/utils/UserPermissionUtils';
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {MAX_GROUP_DM_RECIPIENTS, MAX_GROUP_DMS_PER_USER} from '@fluxer/constants/src/LimitConstants';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {CannotSendMessagesToUserError} from '@fluxer/errors/src/domains/channel/CannotSendMessagesToUserError';
import {MaxGroupDmRecipientsError} from '@fluxer/errors/src/domains/channel/MaxGroupDmRecipientsError';
import {MaxGroupDmsError} from '@fluxer/errors/src/domains/channel/MaxGroupDmsError';
import {UnclaimedAccountCannotSendDirectMessagesError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotSendDirectMessagesError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {NotFriendsWithUserError} from '@fluxer/errors/src/domains/user/NotFriendsWithUserError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
export class UserChannelService {
constructor(
private userAccountRepository: IUserAccountRepository,
private userChannelRepository: IUserChannelRepository,
private userRelationshipRepository: IUserRelationshipRepository,
private channelService: ChannelService,
private channelRepository: IChannelRepository,
private gatewayService: IGatewayService,
private mediaService: IMediaService,
private snowflakeService: SnowflakeService,
private userPermissionUtils: UserPermissionUtils,
private readonly limitConfigService: LimitConfigService,
) {}
async getPrivateChannels(userId: UserID): Promise<Array<Channel>> {
return await this.userChannelRepository.listPrivateChannels(userId);
}
async createOrOpenDMChannel({
userId,
data,
userCacheService,
requestCache,
}: {
userId: UserID;
data: CreatePrivateChannelRequest;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
if (data.recipients !== undefined) {
return await this.createGroupDMChannel({
userId,
recipients: data.recipients,
userCacheService,
requestCache,
});
}
if (!data.recipient_id) {
throw InputValidationError.fromCode('recipient_id', ValidationErrorCodes.RECIPIENT_IDS_CANNOT_BE_EMPTY);
}
const recipientId = createUserID(data.recipient_id);
if (userId === recipientId) {
throw InputValidationError.fromCode('recipient_id', ValidationErrorCodes.CANNOT_DM_YOURSELF);
}
const targetUser = await this.userAccountRepository.findUnique(recipientId);
if (!targetUser) throw new UnknownUserError();
const existingChannel = await this.userChannelRepository.findExistingDmState(userId, recipientId);
if (existingChannel) {
return await this.reopenExistingDMChannel({userId, existingChannel, userCacheService, requestCache});
}
await this.validateDmPermission(userId, recipientId, targetUser);
const channel = await this.createNewDMChannel({userId, recipientId, userCacheService, requestCache});
return channel;
}
async pinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
const channel = await this.channelService.getChannel({userId, channelId});
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw InputValidationError.fromCode('channel_id', ValidationErrorCodes.CHANNEL_MUST_BE_DM_OR_GROUP_DM);
}
if (!channel.recipientIds.has(userId)) {
throw new MissingAccessError();
}
const newPinnedDMs = await this.userChannelRepository.addPinnedDm(userId, channelId);
await this.gatewayService.dispatchPresence({
userId: userId,
event: 'USER_PINNED_DMS_UPDATE',
data: newPinnedDMs.map(String),
});
}
async unpinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
const channel = await this.channelService.getChannel({userId, channelId});
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw InputValidationError.fromCode('channel_id', ValidationErrorCodes.CHANNEL_MUST_BE_DM_OR_GROUP_DM);
}
if (!channel.recipientIds.has(userId)) {
throw new MissingAccessError();
}
const newPinnedDMs = await this.userChannelRepository.removePinnedDm(userId, channelId);
await this.gatewayService.dispatchPresence({
userId: userId,
event: 'USER_PINNED_DMS_UPDATE',
data: newPinnedDMs.map(String),
});
}
async preloadDMMessages(params: {
userId: UserID;
channelIds: Array<ChannelID>;
}): Promise<Record<string, Message | null>> {
const {userId, channelIds} = params;
if (channelIds.length > 100) {
throw InputValidationError.fromCode('channels', ValidationErrorCodes.CANNOT_PRELOAD_MORE_THAN_100_CHANNELS);
}
const results: Record<string, Message | null> = {};
const fetchPromises = channelIds.map(async (channelId) => {
try {
const channel = await this.channelService.getChannel({userId, channelId});
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
return;
}
if (!channel.recipientIds.has(userId)) {
return;
}
const messages = await this.channelService.getMessages({
userId,
channelId,
limit: 1,
before: undefined,
after: undefined,
around: undefined,
});
results[channelId.toString()] = messages[0] ?? null;
} catch {
results[channelId.toString()] = null;
}
});
await Promise.all(fetchPromises);
return results;
}
async getExistingDmForUsers(userId: UserID, recipientId: UserID): Promise<Channel | null> {
return await this.userChannelRepository.findExistingDmState(userId, recipientId);
}
async ensureDmOpenForBothUsers({
userId,
recipientId,
userCacheService,
requestCache,
}: {
userId: UserID;
recipientId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
const existingChannel = await this.userChannelRepository.findExistingDmState(userId, recipientId);
if (existingChannel) {
const [isUserOpen, isRecipientOpen] = await Promise.all([
this.userChannelRepository.isDmChannelOpen(userId, existingChannel.id),
this.userChannelRepository.isDmChannelOpen(recipientId, existingChannel.id),
]);
if (!isUserOpen) {
await this.userChannelRepository.openDmForUser(userId, existingChannel.id);
await this.dispatchChannelCreate({userId, channel: existingChannel, userCacheService, requestCache});
}
if (!isRecipientOpen) {
await this.userChannelRepository.openDmForUser(recipientId, existingChannel.id);
await this.dispatchChannelCreate({
userId: recipientId,
channel: existingChannel,
userCacheService,
requestCache,
});
}
return existingChannel;
}
return await this.createNewDmForBothUsers({userId, recipientId, userCacheService, requestCache});
}
async reopenDmForBothUsers({
userId,
recipientId,
existingChannel,
userCacheService,
requestCache,
}: {
userId: UserID;
recipientId: UserID;
existingChannel: Channel;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
await this.reopenExistingDMChannel({userId, existingChannel, userCacheService, requestCache});
await this.reopenExistingDMChannel({
userId: recipientId,
existingChannel,
userCacheService,
requestCache,
});
}
async createNewDmForBothUsers({
userId,
recipientId,
userCacheService,
requestCache,
}: {
userId: UserID;
recipientId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
const newChannel = await this.createNewDMChannel({
userId,
recipientId,
userCacheService,
requestCache,
});
await this.userChannelRepository.openDmForUser(recipientId, newChannel.id);
await this.dispatchChannelCreate({userId: recipientId, channel: newChannel, userCacheService, requestCache});
return newChannel;
}
private async reopenExistingDMChannel({
userId,
existingChannel,
userCacheService,
requestCache,
}: {
userId: UserID;
existingChannel: Channel;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
await this.userChannelRepository.openDmForUser(userId, existingChannel.id);
await this.dispatchChannelCreate({userId, channel: existingChannel, userCacheService, requestCache});
return existingChannel;
}
private async createNewDMChannel({
userId,
recipientId,
userCacheService,
requestCache,
}: {
userId: UserID;
recipientId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
const channelId = createChannelID(await this.snowflakeService.generate());
const newChannel = await this.userChannelRepository.createDmChannelAndState(userId, recipientId, channelId);
await this.userChannelRepository.openDmForUser(userId, channelId);
await this.dispatchChannelCreate({userId, channel: newChannel, userCacheService, requestCache});
return newChannel;
}
private async createGroupDMChannel({
userId,
recipients,
userCacheService,
requestCache,
}: {
userId: UserID;
recipients: Array<bigint>;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
const fallbackRecipientLimit = MAX_GROUP_DM_RECIPIENTS;
const recipientLimit = this.resolveLimitForUser(
await this.userAccountRepository.findUnique(userId),
'max_group_dm_recipients',
fallbackRecipientLimit,
);
if (recipients.length > recipientLimit) {
throw new MaxGroupDmRecipientsError(recipientLimit);
}
const recipientIds = recipients.map(createUserID);
const uniqueRecipientIds = new Set(recipientIds);
if (uniqueRecipientIds.size !== recipientIds.length) {
throw InputValidationError.fromCode('recipients', ValidationErrorCodes.DUPLICATE_RECIPIENTS_NOT_ALLOWED);
}
if (uniqueRecipientIds.has(userId)) {
throw InputValidationError.fromCode('recipients', ValidationErrorCodes.CANNOT_ADD_YOURSELF_TO_GROUP_DM);
}
const usersToCheck = new Set<UserID>([userId, ...recipientIds]);
await this.ensureUsersWithinGroupDmLimit(usersToCheck);
for (const recipientId of recipientIds) {
const targetUser = await this.userAccountRepository.findUnique(recipientId);
if (!targetUser) {
throw new UnknownUserError();
}
const friendship = await this.userRelationshipRepository.getRelationship(
userId,
recipientId,
RelationshipTypes.FRIEND,
);
if (!friendship) {
throw new NotFriendsWithUserError();
}
await this.userPermissionUtils.validateGroupDmAddPermissions({userId, targetId: recipientId});
}
const channelId = createChannelID(await this.snowflakeService.generate());
const allRecipients = new Set([userId, ...recipientIds]);
const channelData = {
channel_id: channelId,
guild_id: null,
type: ChannelTypes.GROUP_DM,
name: null,
topic: null,
icon_hash: null,
url: null,
parent_id: null,
position: 0,
owner_id: userId,
recipient_ids: allRecipients,
nsfw: false,
rate_limit_per_user: 0,
bitrate: null,
user_limit: null,
rtc_region: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
nicks: null,
soft_deleted: false,
indexed_at: null,
version: 1,
};
const newChannel = await this.channelRepository.upsert(channelData);
for (const recipientId of allRecipients) {
await this.userChannelRepository.openDmForUser(recipientId, channelId);
}
const systemMessages: Array<Message> = [];
for (const recipientId of recipientIds) {
const messageId = createMessageID(await this.snowflakeService.generate());
const message = await this.channelRepository.upsertMessage({
channel_id: channelId,
bucket: BucketUtils.makeBucket(messageId),
message_id: messageId,
author_id: userId,
type: MessageTypes.RECIPIENT_ADD,
webhook_id: null,
webhook_name: null,
webhook_avatar_hash: null,
content: null,
edited_timestamp: null,
pinned_timestamp: null,
flags: 0,
mention_everyone: false,
mention_users: new Set([recipientId]),
mention_roles: null,
mention_channels: null,
attachments: null,
embeds: null,
sticker_items: null,
message_reference: null,
message_snapshots: null,
call: null,
has_reaction: false,
version: 1,
});
systemMessages.push(message);
}
for (const recipientId of allRecipients) {
await this.dispatchChannelCreate({userId: recipientId, channel: newChannel, userCacheService, requestCache});
}
for (const message of systemMessages) {
await this.dispatchSystemMessage({
channel: newChannel,
message,
userCacheService,
requestCache,
});
}
return newChannel;
}
private async dispatchSystemMessage({
channel,
message,
userCacheService,
requestCache,
}: {
channel: Channel;
message: Message;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
await dispatchMessageCreate({
channel,
message,
requestCache,
userCacheService,
gatewayService: this.gatewayService,
mediaService: this.mediaService,
getReferencedMessage: (channelId, messageId) => this.channelRepository.getMessage(channelId, messageId),
});
}
private async dispatchChannelCreate({
userId,
channel,
userCacheService,
requestCache,
}: {
userId: UserID;
channel: Channel;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
const channelResponse = await mapChannelToResponse({
channel,
currentUserId: userId,
userCacheService,
requestCache,
});
await this.gatewayService.dispatchPresence({
userId,
event: 'CHANNEL_CREATE',
data: channelResponse,
});
}
private async ensureUsersWithinGroupDmLimit(userIds: Iterable<UserID>): Promise<void> {
for (const userId of userIds) {
await this.ensureUserWithinGroupDmLimit(userId);
}
}
private async ensureUserWithinGroupDmLimit(userId: UserID): Promise<void> {
const summaries = await this.userChannelRepository.listPrivateChannelSummaries(userId);
const openGroupDms = summaries.filter((summary) => summary.open && summary.isGroupDm).length;
const user = await this.userAccountRepository.findUnique(userId);
const fallbackLimit = MAX_GROUP_DMS_PER_USER;
const limit = this.resolveLimitForUser(user ?? null, 'max_group_dms_per_user', fallbackLimit);
if (openGroupDms >= limit) {
throw new MaxGroupDmsError(limit);
}
}
private async validateDmPermission(userId: UserID, recipientId: UserID, _recipientUser?: User | null): Promise<void> {
const senderUser = await this.userAccountRepository.findUnique(userId);
if (senderUser?.isUnclaimedAccount()) {
throw new UnclaimedAccountCannotSendDirectMessagesError();
}
const userBlockedRecipient = await this.userRelationshipRepository.getRelationship(
userId,
recipientId,
RelationshipTypes.BLOCKED,
);
if (userBlockedRecipient) {
throw new CannotSendMessagesToUserError();
}
const recipientBlockedUser = await this.userRelationshipRepository.getRelationship(
recipientId,
userId,
RelationshipTypes.BLOCKED,
);
if (recipientBlockedUser) {
throw new CannotSendMessagesToUserError();
}
const friendship = await this.userRelationshipRepository.getRelationship(
userId,
recipientId,
RelationshipTypes.FRIEND,
);
if (friendship) return;
const hasMutualGuilds = await this.userPermissionUtils.checkMutualGuildsAsync({
userId,
targetId: recipientId,
});
if (hasMutualGuilds) return;
throw new CannotSendMessagesToUserError();
}
private resolveLimitForUser(user: User | null, key: LimitKey, fallback: number): number {
const ctx = createLimitMatchContext({user});
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback);
}
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import type {UserContactChangeLogRow} from '@fluxer/api/src/database/types/UserTypes';
import type {User} from '@fluxer/api/src/models/User';
import type {UserContactChangeLogRepository} from '@fluxer/api/src/user/repositories/UserContactChangeLogRepository';
export type ContactChangeReason = 'user_requested' | 'admin_action';
interface RecordDiffParams {
oldUser: User | null;
newUser: User;
reason: ContactChangeReason;
actorUserId: UserID | null;
eventAt?: Date;
}
interface ListLogsParams {
userId: UserID;
limit?: number;
beforeEventId?: string;
}
export class UserContactChangeLogService {
private readonly DEFAULT_LIMIT = 50;
constructor(private readonly repo: UserContactChangeLogRepository) {}
async recordDiff(params: RecordDiffParams): Promise<void> {
const {oldUser, newUser, reason, actorUserId, eventAt} = params;
const tasks: Array<Promise<void>> = [];
const oldEmail = oldUser?.email?.toLowerCase() ?? null;
const newEmail = newUser.email?.toLowerCase() ?? null;
if (oldEmail !== newEmail) {
tasks.push(
this.repo.insertLog({
userId: newUser.id,
field: 'email',
oldValue: oldEmail,
newValue: newEmail,
reason,
actorUserId,
eventAt,
}),
);
}
const oldPhone = oldUser?.phone ?? null;
const newPhone = newUser.phone ?? null;
if (oldPhone !== newPhone) {
tasks.push(
this.repo.insertLog({
userId: newUser.id,
field: 'phone',
oldValue: oldPhone,
newValue: newPhone,
reason,
actorUserId,
eventAt,
}),
);
}
const oldTag = oldUser ? this.buildFluxerTag(oldUser) : null;
const newTag = this.buildFluxerTag(newUser);
if (oldTag !== newTag) {
tasks.push(
this.repo.insertLog({
userId: newUser.id,
field: 'fluxer_tag',
oldValue: oldTag,
newValue: newTag,
reason,
actorUserId,
eventAt,
}),
);
}
if (tasks.length > 0) {
await Promise.all(tasks);
}
}
async listLogs(params: ListLogsParams): Promise<Array<UserContactChangeLogRow>> {
const {userId, beforeEventId} = params;
const limit = params.limit ?? this.DEFAULT_LIMIT;
return this.repo.listLogs({userId, limit, beforeEventId});
}
private buildFluxerTag(user: User | null): string | null {
if (!user) return null;
const discriminator = user.discriminator?.toString() ?? '';
if (!user.username || discriminator === '') {
return null;
}
const paddedDiscriminator = discriminator.padStart(4, '0');
return `${user.username}#${paddedDiscriminator}`;
}
}

View File

@@ -0,0 +1,181 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {SavedMessageEntry} from '@fluxer/api/src/user/services/UserContentService';
import type {UserService} from '@fluxer/api/src/user/services/UserService';
import type {MessageListResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {
HarvestCreationResponseSchema,
HarvestDownloadUrlResponse,
HarvestStatusResponseSchema,
} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
import type {
SavedMessageEntryListResponse,
SavedMessageEntryResponse,
} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {z} from 'zod';
type HarvestCreationResponse = z.infer<typeof HarvestCreationResponseSchema>;
type HarvestStatusResponse = z.infer<typeof HarvestStatusResponseSchema>;
type HarvestLatestResponse = HarvestStatusResponse | null;
interface UserMentionsParams {
userId: UserID;
limit: number;
roles: boolean;
everyone: boolean;
guilds: boolean;
before?: MessageID;
requestCache: RequestCache;
}
interface UserMentionDeleteParams {
userId: UserID;
messageId: MessageID;
}
interface SavedMessagesParams {
userId: UserID;
limit: number;
requestCache: RequestCache;
}
interface SaveMessageParams {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
}
interface UnsaveMessageParams {
userId: UserID;
messageId: MessageID;
}
interface HarvestRequestParams {
userId: UserID;
}
interface HarvestStatusParams {
userId: UserID;
harvestId: bigint;
}
interface HarvestDownloadParams {
userId: UserID;
harvestId: bigint;
storageService: IStorageService;
}
export class UserContentRequestService {
constructor(
private readonly userService: UserService,
private readonly userCacheService: UserCacheService,
private readonly mediaService: IMediaService,
) {}
async listMentions(params: UserMentionsParams): Promise<MessageListResponse> {
const messages = await this.userService.getRecentMentions({
userId: params.userId,
limit: params.limit,
everyone: params.everyone,
roles: params.roles,
guilds: params.guilds,
before: params.before,
});
return Promise.all(
messages.map((message) =>
mapMessageToResponse({
message,
currentUserId: params.userId,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
mediaService: this.mediaService,
}),
),
);
}
async deleteMention(params: UserMentionDeleteParams): Promise<void> {
await this.userService.deleteRecentMention({userId: params.userId, messageId: params.messageId});
}
async listSavedMessages(params: SavedMessagesParams): Promise<SavedMessageEntryListResponse> {
const entries = await this.userService.getSavedMessages({userId: params.userId, limit: params.limit});
return Promise.all(entries.map((entry) => this.mapSavedMessageEntry(params.userId, entry, params.requestCache)));
}
async saveMessage(params: SaveMessageParams): Promise<void> {
await this.userService.saveMessage({
userId: params.userId,
channelId: params.channelId,
messageId: params.messageId,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
}
async unsaveMessage(params: UnsaveMessageParams): Promise<void> {
await this.userService.unsaveMessage({userId: params.userId, messageId: params.messageId});
}
async requestHarvest(params: HarvestRequestParams): Promise<HarvestCreationResponse> {
return this.userService.requestDataHarvest(params.userId);
}
async getLatestHarvest(params: HarvestRequestParams): Promise<HarvestLatestResponse> {
return this.userService.getLatestHarvest(params.userId);
}
async getHarvestStatus(params: HarvestStatusParams): Promise<HarvestStatusResponse> {
return this.userService.getHarvestStatus(params.userId, params.harvestId);
}
async getHarvestDownloadUrl(params: HarvestDownloadParams): Promise<HarvestDownloadUrlResponse> {
return this.userService.getHarvestDownloadUrl(params.userId, params.harvestId, params.storageService);
}
private async mapSavedMessageEntry(
userId: UserID,
entry: SavedMessageEntry,
requestCache: RequestCache,
): Promise<SavedMessageEntryResponse> {
return {
id: entry.messageId.toString(),
channel_id: entry.channelId.toString(),
message_id: entry.messageId.toString(),
status: entry.status,
message: entry.message
? await mapMessageToResponse({
message: entry.message,
currentUserId: userId,
userCacheService: this.userCacheService,
requestCache,
mediaService: this.mediaService,
})
: null,
};
}
}

View File

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

View File

@@ -0,0 +1,126 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {User} from '@fluxer/api/src/models/User';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
import {ms, seconds} from 'itty-time';
export class UserDeletionEligibilityService {
private readonly INACTIVITY_WARNING_TTL_DAYS = 30;
private readonly INACTIVITY_WARNING_PREFIX = 'inactivity_warning_sent';
constructor(private kvClient: IKVProvider) {}
async isEligibleForInactivityDeletion(user: User): Promise<boolean> {
if (user.isBot) {
return false;
}
if (user.isSystem) {
return false;
}
if (this.isAppStoreReviewer(user)) {
return false;
}
if (user.pendingDeletionAt !== null) {
return false;
}
if (user.lastActiveAt === null) {
return false;
}
const inactivityThresholdMs = this.getInactivityThresholdMs();
const timeSinceLastActiveMs = Date.now() - user.lastActiveAt.getTime();
if (timeSinceLastActiveMs < inactivityThresholdMs) {
return false;
}
return true;
}
async isEligibleForWarningEmail(user: User): Promise<boolean> {
const isEligibleForDeletion = await this.isEligibleForInactivityDeletion(user);
if (!isEligibleForDeletion) {
return false;
}
const alreadySentWarning = await this.hasWarningSent(user.id);
if (alreadySentWarning) {
return false;
}
return true;
}
async markWarningSent(userId: UserID): Promise<void> {
const key = this.getWarningKey(userId);
const ttlSeconds = seconds(`${this.INACTIVITY_WARNING_TTL_DAYS + 5} days`);
const timestamp = Date.now().toString();
await this.kvClient.setex(key, ttlSeconds, timestamp);
}
async hasWarningSent(userId: UserID): Promise<boolean> {
const key = this.getWarningKey(userId);
const exists = await this.kvClient.exists(key);
return exists === 1;
}
async getWarningSentTimestamp(userId: UserID): Promise<number | null> {
const key = this.getWarningKey(userId);
const value = await this.kvClient.get(key);
if (!value) {
return null;
}
const timestamp = parseInt(value, 10);
return Number.isNaN(timestamp) ? null : timestamp;
}
async hasWarningGracePeriodExpired(userId: UserID): Promise<boolean> {
const timestamp = await this.getWarningSentTimestamp(userId);
if (timestamp === null) {
return false;
}
const timeSinceWarningMs = Date.now() - timestamp;
const gracePeriodMs = this.INACTIVITY_WARNING_TTL_DAYS * ms('1 day');
return timeSinceWarningMs >= gracePeriodMs;
}
private getInactivityThresholdMs(): number {
const thresholdDays = Config.inactivityDeletionThresholdDays ?? 365 * 2;
return thresholdDays * ms('1 day');
}
private getWarningKey(userId: UserID): string {
return `${this.INACTIVITY_WARNING_PREFIX}:${userId}`;
}
private isAppStoreReviewer(user: User): boolean {
return (user.flags & UserFlags.APP_STORE_REVIEWER) !== 0n;
}
}

View File

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

View File

@@ -0,0 +1,146 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {UserService} from '@fluxer/api/src/user/services/UserService';
import {getCachedUserPartialResponse} from '@fluxer/api/src/user/UserCacheHelpers';
import {mapRelationshipToResponse} from '@fluxer/api/src/user/UserMappers';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import type {
FriendRequestByTagRequest,
RelationshipNicknameUpdateRequest,
RelationshipTypePutRequest,
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {RelationshipResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
interface RelationshipListParams {
userId: UserID;
requestCache: RequestCache;
}
interface RelationshipSendByTagParams {
userId: UserID;
data: FriendRequestByTagRequest;
requestCache: RequestCache;
}
interface RelationshipSendParams {
userId: UserID;
targetId: UserID;
requestCache: RequestCache;
}
interface RelationshipUpdateTypeParams {
userId: UserID;
targetId: UserID;
data: RelationshipTypePutRequest;
requestCache: RequestCache;
}
interface RelationshipDeleteParams {
userId: UserID;
targetId: UserID;
}
interface RelationshipNicknameParams {
userId: UserID;
targetId: UserID;
data: RelationshipNicknameUpdateRequest;
requestCache: RequestCache;
}
export class UserRelationshipRequestService {
constructor(
private readonly userService: UserService,
private readonly userCacheService: UserCacheService,
) {}
async listRelationships(params: RelationshipListParams): Promise<Array<RelationshipResponse>> {
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
const relationships = await this.userService.getRelationships(params.userId);
return Promise.all(
relationships.map((relationship) => mapRelationshipToResponse({relationship, userPartialResolver})),
);
}
async sendFriendRequestByTag(params: RelationshipSendByTagParams): Promise<RelationshipResponse> {
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
const relationship = await this.userService.sendFriendRequestByTag({
userId: params.userId,
data: params.data,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
return mapRelationshipToResponse({relationship, userPartialResolver});
}
async sendFriendRequest(params: RelationshipSendParams): Promise<RelationshipResponse> {
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
const relationship = await this.userService.sendFriendRequest({
userId: params.userId,
targetId: params.targetId,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
return mapRelationshipToResponse({relationship, userPartialResolver});
}
async updateRelationshipType(params: RelationshipUpdateTypeParams): Promise<RelationshipResponse> {
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
if (params.data?.type === RelationshipTypes.BLOCKED) {
const relationship = await this.userService.blockUser({
userId: params.userId,
targetId: params.targetId,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
return mapRelationshipToResponse({relationship, userPartialResolver});
}
const relationship = await this.userService.acceptFriendRequest({
userId: params.userId,
targetId: params.targetId,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
return mapRelationshipToResponse({relationship, userPartialResolver});
}
async removeRelationship(params: RelationshipDeleteParams): Promise<void> {
await this.userService.removeRelationship({userId: params.userId, targetId: params.targetId});
}
async updateNickname(params: RelationshipNicknameParams): Promise<RelationshipResponse> {
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
const relationship = await this.userService.updateFriendNickname({
userId: params.userId,
targetId: params.targetId,
nickname: params.data.nickname ?? null,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
return mapRelationshipToResponse({relationship, userPartialResolver});
}
private createUserPartialResolver(requestCache: RequestCache) {
return (userId: UserID) =>
getCachedUserPartialResponse({userId, userCacheService: this.userCacheService, requestCache});
}
}

View File

@@ -0,0 +1,554 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {Relationship} from '@fluxer/api/src/models/Relationship';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
import {getCachedUserPartialResponse} from '@fluxer/api/src/user/UserCacheHelpers';
import {mapRelationshipToResponse} from '@fluxer/api/src/user/UserMappers';
import type {UserPermissionUtils} from '@fluxer/api/src/utils/UserPermissionUtils';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {MAX_RELATIONSHIPS} from '@fluxer/constants/src/LimitConstants';
import {RelationshipTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
import {BotsCannotSendFriendRequestsError} from '@fluxer/errors/src/domains/oauth/BotsCannotSendFriendRequestsError';
import {AlreadyFriendsError} from '@fluxer/errors/src/domains/user/AlreadyFriendsError';
import {CannotSendFriendRequestToBlockedUserError} from '@fluxer/errors/src/domains/user/CannotSendFriendRequestToBlockedUserError';
import {CannotSendFriendRequestToSelfError} from '@fluxer/errors/src/domains/user/CannotSendFriendRequestToSelfError';
import {FriendRequestBlockedError} from '@fluxer/errors/src/domains/user/FriendRequestBlockedError';
import {InvalidDiscriminatorError} from '@fluxer/errors/src/domains/user/InvalidDiscriminatorError';
import {MaxRelationshipsError} from '@fluxer/errors/src/domains/user/MaxRelationshipsError';
import {NoUsersWithFluxertagError} from '@fluxer/errors/src/domains/user/NoUsersWithFluxertagError';
import {UnclaimedAccountCannotAcceptFriendRequestsError} from '@fluxer/errors/src/domains/user/UnclaimedAccountCannotAcceptFriendRequestsError';
import {UnclaimedAccountCannotSendFriendRequestsError} from '@fluxer/errors/src/domains/user/UnclaimedAccountCannotSendFriendRequestsError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {FriendRequestByTagRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
export class UserRelationshipService {
constructor(
private userAccountRepository: IUserAccountRepository,
private userRelationshipRepository: IUserRelationshipRepository,
private gatewayService: IGatewayService,
private userPermissionUtils: UserPermissionUtils,
private readonly limitConfigService: LimitConfigService,
) {}
async getRelationships(userId: UserID): Promise<Array<Relationship>> {
return await this.userRelationshipRepository.listRelationships(userId);
}
async sendFriendRequestByTag({
userId,
data,
userCacheService,
requestCache,
}: {
userId: UserID;
data: FriendRequestByTagRequest;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const {username, discriminator} = data;
const discrimValue = discriminator;
if (!Number.isInteger(discrimValue) || discrimValue < 0 || discrimValue > 9999) {
throw new InvalidDiscriminatorError();
}
const targetUser = await this.userAccountRepository.findByUsernameDiscriminator(username, discrimValue);
if (!targetUser) {
throw new NoUsersWithFluxertagError();
}
if (this.isDeletedUser(targetUser)) {
throw new FriendRequestBlockedError();
}
const existingRelationship = await this.userRelationshipRepository.getRelationship(
userId,
targetUser.id,
RelationshipTypes.FRIEND,
);
if (existingRelationship) {
throw new AlreadyFriendsError();
}
return this.sendFriendRequest({userId, targetId: targetUser.id, userCacheService, requestCache});
}
async sendFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const targetUser = await this.validateFriendRequest({userId, targetId});
const pendingIncoming = await this.userRelationshipRepository.getRelationship(
targetId,
userId,
RelationshipTypes.OUTGOING_REQUEST,
);
if (pendingIncoming) {
return this.acceptFriendRequest({userId, targetId, userCacheService, requestCache});
}
const existingFriendship = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.FRIEND,
);
const existingOutgoingRequest = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.OUTGOING_REQUEST,
);
if (existingFriendship || existingOutgoingRequest) {
const relationships = await this.userRelationshipRepository.listRelationships(userId);
const relationship = relationships.find((r) => r.targetUserId === targetId);
if (relationship) {
return relationship;
}
}
await this.validateRelationshipCounts({userId, targetId});
const requestRelationship = await this.createFriendRequest({userId, targetId, userCacheService, requestCache});
const targetIsFriendlyBot =
targetUser.isBot && (targetUser.flags & UserFlags.FRIENDLY_BOT) === UserFlags.FRIENDLY_BOT;
const manualApprovalFlag = UserFlags.FRIENDLY_BOT_MANUAL_APPROVAL;
const manualApprovalRequired = targetUser.isBot && (targetUser.flags & manualApprovalFlag) === manualApprovalFlag;
if (targetIsFriendlyBot && !manualApprovalRequired) {
const finalFriendship = await this.acceptFriendRequest({
userId: targetId,
targetId: userId,
userCacheService,
requestCache,
});
return finalFriendship;
}
return requestRelationship;
}
async acceptFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const user = await this.userAccountRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
if (this.isDeletedUser(user)) {
throw new FriendRequestBlockedError();
}
if (user?.isUnclaimedAccount()) {
throw new UnclaimedAccountCannotAcceptFriendRequestsError();
}
const requesterUser = await this.userAccountRepository.findUnique(targetId);
if (!requesterUser) {
throw new UnknownUserError();
}
if (this.isDeletedUser(requesterUser)) {
throw new FriendRequestBlockedError();
}
const incomingRequest = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.INCOMING_REQUEST,
);
if (!incomingRequest) {
throw new UnknownUserError();
}
await this.validateRelationshipCounts({userId, targetId});
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.OUTGOING_REQUEST);
const now = new Date();
const userRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: userId,
target_user_id: targetId,
type: RelationshipTypes.FRIEND,
nickname: null,
since: now,
version: 1,
});
const targetRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: targetId,
target_user_id: userId,
type: RelationshipTypes.FRIEND,
nickname: null,
since: now,
version: 1,
});
await this.dispatchRelationshipUpdate({
userId,
relationship: userRelationship,
userCacheService,
requestCache,
});
await this.dispatchRelationshipUpdate({
userId: targetId,
relationship: targetRelationship,
userCacheService,
requestCache,
});
return userRelationship;
}
async blockUser({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const targetUser = await this.userAccountRepository.findUnique(targetId);
if (!targetUser) {
throw new UnknownUserError();
}
const existingBlocked = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.BLOCKED,
);
if (existingBlocked) {
return existingBlocked;
}
const existingFriend = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.FRIEND,
);
const existingIncomingRequest = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.INCOMING_REQUEST,
);
const existingOutgoingRequest = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.OUTGOING_REQUEST,
);
if (existingFriend) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.FRIEND);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.FRIEND);
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
} else if (existingOutgoingRequest) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.INCOMING_REQUEST);
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
} else if (existingIncomingRequest) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST);
}
const now = new Date();
const blockRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: userId,
target_user_id: targetId,
type: RelationshipTypes.BLOCKED,
nickname: null,
since: now,
version: 1,
});
await this.dispatchRelationshipCreate({
userId,
relationship: blockRelationship,
userCacheService,
requestCache,
});
return blockRelationship;
}
async removeRelationship({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
const [friend, incoming, outgoing, blocked] = await Promise.all([
this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.FRIEND),
this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST),
this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST),
this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.BLOCKED),
]);
const existingRelationship = friend || incoming || outgoing || blocked;
if (!existingRelationship) throw new UnknownUserError();
const relationshipType = existingRelationship.type;
if (relationshipType === RelationshipTypes.INCOMING_REQUEST || relationshipType === RelationshipTypes.BLOCKED) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, relationshipType);
await this.dispatchRelationshipRemove({
userId,
targetId: targetId.toString(),
});
return;
}
if (relationshipType === RelationshipTypes.OUTGOING_REQUEST) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.INCOMING_REQUEST);
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
return;
}
if (relationshipType === RelationshipTypes.FRIEND) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.FRIEND);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.FRIEND);
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
return;
}
await this.userRelationshipRepository.deleteRelationship(userId, targetId, relationshipType);
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
}
async updateFriendNickname({
userId,
targetId,
nickname,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
nickname: string | null;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const relationship = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.FRIEND,
);
if (!relationship) {
throw new UnknownUserError();
}
const updatedRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: userId,
target_user_id: targetId,
type: RelationshipTypes.FRIEND,
nickname,
since: relationship.since ?? new Date(),
version: 1,
});
await this.dispatchRelationshipUpdate({
userId,
relationship: updatedRelationship,
userCacheService,
requestCache,
});
return updatedRelationship;
}
private async validateFriendRequest({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<User> {
if (userId === targetId) {
throw new CannotSendFriendRequestToSelfError();
}
const requesterUser = await this.userAccountRepository.findUnique(userId);
if (!requesterUser) {
throw new UnknownUserError();
}
if (this.isDeletedUser(requesterUser)) {
throw new FriendRequestBlockedError();
}
if (requesterUser?.isUnclaimedAccount()) {
throw new UnclaimedAccountCannotSendFriendRequestsError();
}
if (requesterUser?.isBot) {
throw new BotsCannotSendFriendRequestsError();
}
const targetUser = await this.userAccountRepository.findUnique(targetId);
if (!targetUser) throw new UnknownUserError();
if (this.isDeletedUser(targetUser)) {
throw new FriendRequestBlockedError();
}
const targetIsFriendlyBot =
targetUser.isBot && (targetUser.flags & UserFlags.FRIENDLY_BOT) === UserFlags.FRIENDLY_BOT;
if (targetUser.isBot && !targetIsFriendlyBot) {
throw new FriendRequestBlockedError();
}
if (targetUser.flags & UserFlags.APP_STORE_REVIEWER) {
throw new FriendRequestBlockedError();
}
const requesterBlockedTarget = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.BLOCKED,
);
if (requesterBlockedTarget) {
throw new CannotSendFriendRequestToBlockedUserError();
}
const targetBlockedRequester = await this.userRelationshipRepository.getRelationship(
targetId,
userId,
RelationshipTypes.BLOCKED,
);
if (targetBlockedRequester) {
throw new FriendRequestBlockedError();
}
await this.userPermissionUtils.validateFriendSourcePermissions({userId, targetId});
return targetUser;
}
private async validateRelationshipCounts({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
const user = await this.userAccountRepository.findUnique(userId);
const targetUser = await this.userAccountRepository.findUnique(targetId);
if (!user?.isBot) {
const userLimit = this.resolveLimitForUser(user ?? null, 'max_relationships', MAX_RELATIONSHIPS);
const hasReachedLimit = await this.userRelationshipRepository.hasReachedRelationshipLimit(userId, userLimit);
if (hasReachedLimit) {
throw new MaxRelationshipsError(userLimit);
}
}
if (!targetUser?.isBot) {
const targetLimit = this.resolveLimitForUser(targetUser ?? null, 'max_relationships', MAX_RELATIONSHIPS);
const hasReachedLimit = await this.userRelationshipRepository.hasReachedRelationshipLimit(targetId, targetLimit);
if (hasReachedLimit) {
throw new MaxRelationshipsError(targetLimit);
}
}
}
private async createFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const now = new Date();
const userRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: userId,
target_user_id: targetId,
type: RelationshipTypes.OUTGOING_REQUEST,
nickname: null,
since: now,
version: 1,
});
const targetRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: targetId,
target_user_id: userId,
type: RelationshipTypes.INCOMING_REQUEST,
nickname: null,
since: now,
version: 1,
});
await this.dispatchRelationshipCreate({userId, relationship: userRelationship, userCacheService, requestCache});
await this.dispatchRelationshipCreate({
userId: targetId,
relationship: targetRelationship,
userCacheService,
requestCache,
});
return userRelationship;
}
async dispatchRelationshipCreate({
userId,
relationship,
userCacheService,
requestCache,
}: {
userId: UserID;
relationship: Relationship;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
const userPartialResolver = (userId: UserID) =>
getCachedUserPartialResponse({userId, userCacheService, requestCache});
await this.gatewayService.dispatchPresence({
userId,
event: 'RELATIONSHIP_ADD',
data: await mapRelationshipToResponse({relationship, userPartialResolver}),
});
}
async dispatchRelationshipUpdate({
userId,
relationship,
userCacheService,
requestCache,
}: {
userId: UserID;
relationship: Relationship;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
const userPartialResolver = (userId: UserID) =>
getCachedUserPartialResponse({userId, userCacheService, requestCache});
await this.gatewayService.dispatchPresence({
userId,
event: 'RELATIONSHIP_UPDATE',
data: await mapRelationshipToResponse({relationship, userPartialResolver}),
});
}
async dispatchRelationshipRemove({userId, targetId}: {userId: UserID; targetId: string}): Promise<void> {
await this.gatewayService.dispatchPresence({
userId,
event: 'RELATIONSHIP_REMOVE',
data: {id: targetId},
});
}
private resolveLimitForUser(user: User | null, key: LimitKey, fallback: number): number {
const ctx = createLimitMatchContext({user});
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback);
}
private isDeletedUser(user: User | null | undefined): boolean {
if (!user) {
return false;
}
return (user.flags & UserFlags.DELETED) === UserFlags.DELETED;
}
}

View File

@@ -0,0 +1,622 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
import type {ChannelID, GuildID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
import type {IConnectionRepository} from '@fluxer/api/src/connection/IConnectionRepository';
import type {UserConnectionRow} from '@fluxer/api/src/database/types/ConnectionTypes';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
import type {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import type {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAccountDeletionQueueService';
import type {KVBulkMessageDeletionQueueService} from '@fluxer/api/src/infrastructure/KVBulkMessageDeletionQueueService';
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {Channel} from '@fluxer/api/src/models/Channel';
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
import type {Message} from '@fluxer/api/src/models/Message';
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
import type {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
import type {Relationship} from '@fluxer/api/src/models/Relationship';
import type {User} from '@fluxer/api/src/models/User';
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
import type {BotMfaMirrorService} from '@fluxer/api/src/oauth/BotMfaMirrorService';
import type {PackService} from '@fluxer/api/src/pack/PackService';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserAuthRepository} from '@fluxer/api/src/user/repositories/IUserAuthRepository';
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
import type {IUserContentRepository} from '@fluxer/api/src/user/repositories/IUserContentRepository';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
import type {IUserSettingsRepository} from '@fluxer/api/src/user/repositories/IUserSettingsRepository';
import {UserAccountService} from '@fluxer/api/src/user/services/UserAccountService';
import {UserAuthService} from '@fluxer/api/src/user/services/UserAuthService';
import {UserChannelService} from '@fluxer/api/src/user/services/UserChannelService';
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
import type {SavedMessageEntry} from '@fluxer/api/src/user/services/UserContentService';
import {UserContentService} from '@fluxer/api/src/user/services/UserContentService';
import {UserRelationshipService} from '@fluxer/api/src/user/services/UserRelationshipService';
import type {UserHarvestResponse} from '@fluxer/api/src/user/UserHarvestModel';
import type {UserPermissionUtils} from '@fluxer/api/src/utils/UserPermissionUtils';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {
CreatePrivateChannelRequest,
FriendRequestByTagRequest,
UserGuildSettingsUpdateRequest,
UserSettingsUpdateRequest,
UserUpdateRequest,
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
interface UpdateUserParams {
user: User;
oldAuthSession: AuthSession;
data: UserUpdateRequest;
request: Request;
sudoContext?: SudoVerificationResult;
emailVerifiedViaToken?: boolean;
}
export class UserService {
private accountService: UserAccountService;
private authService: UserAuthService;
private relationshipService: UserRelationshipService;
private channelService: UserChannelService;
private contentService: UserContentService;
constructor(
userAccountRepository: IUserAccountRepository,
userSettingsRepository: IUserSettingsRepository,
userAuthRepository: IUserAuthRepository,
userRelationshipRepository: IUserRelationshipRepository,
userChannelRepository: IUserChannelRepository,
userContentRepository: IUserContentRepository,
authService: AuthService,
userCacheService: UserCacheService,
channelService: ChannelService,
channelRepository: IChannelRepository,
guildService: GuildService,
gatewayService: IGatewayService,
entityAssetService: EntityAssetService,
mediaService: IMediaService,
packService: PackService,
emailService: IEmailService,
snowflakeService: SnowflakeService,
discriminatorService: IDiscriminatorService,
rateLimitService: IRateLimitService,
guildRepository: IGuildRepositoryAggregate,
workerService: IWorkerService,
userPermissionUtils: UserPermissionUtils,
kvDeletionQueue: KVAccountDeletionQueueService,
bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService,
botMfaMirrorService: BotMfaMirrorService,
contactChangeLogService: UserContactChangeLogService,
connectionRepository: IConnectionRepository,
limitConfigService: LimitConfigService,
) {
this.accountService = new UserAccountService(
userAccountRepository,
userSettingsRepository,
userRelationshipRepository,
userChannelRepository,
authService,
userCacheService,
guildService,
gatewayService,
entityAssetService,
mediaService,
packService,
emailService,
rateLimitService,
guildRepository,
discriminatorService,
kvDeletionQueue,
contactChangeLogService,
connectionRepository,
limitConfigService,
);
this.authService = new UserAuthService(
userAccountRepository,
userAuthRepository,
authService,
emailService,
gatewayService,
botMfaMirrorService,
);
this.relationshipService = new UserRelationshipService(
userAccountRepository,
userRelationshipRepository,
gatewayService,
userPermissionUtils,
limitConfigService,
);
this.channelService = new UserChannelService(
userAccountRepository,
userChannelRepository,
userRelationshipRepository,
channelService,
channelRepository,
gatewayService,
mediaService,
snowflakeService,
userPermissionUtils,
limitConfigService,
);
this.contentService = new UserContentService(
userAccountRepository,
userContentRepository,
userCacheService,
channelService,
channelRepository,
gatewayService,
mediaService,
workerService,
snowflakeService,
bulkMessageDeletionQueue,
limitConfigService,
);
}
async findUnique(userId: UserID): Promise<User | null> {
return await this.accountService.findUnique(userId);
}
async findUniqueAssert(userId: UserID): Promise<User> {
return await this.accountService.findUniqueAssert(userId);
}
async getUserProfile(params: {
userId: UserID;
targetId: UserID;
guildId?: GuildID;
withMutualFriends?: boolean;
withMutualGuilds?: boolean;
requestCache: RequestCache;
}): Promise<{
user: User;
guildMember?: GuildMemberResponse | null;
guildMemberDomain?: GuildMember | null;
premiumType?: number;
premiumSince?: Date;
premiumLifetimeSequence?: number;
mutualFriends?: Array<User>;
mutualGuilds?: Array<{id: string; nick: string | null}>;
connections?: Array<UserConnectionRow>;
}> {
return await this.accountService.getUserProfile(params);
}
async update(params: UpdateUserParams): Promise<User> {
return await this.accountService.update(params);
}
async generateUniqueDiscriminator(username: string): Promise<number> {
return await this.accountService.generateUniqueDiscriminator(username);
}
async checkUsernameDiscriminatorAvailability(params: {username: string; discriminator: number}): Promise<boolean> {
return await this.accountService.checkUsernameDiscriminatorAvailability(params);
}
async findSettings(userId: UserID): Promise<UserSettings> {
return await this.accountService.findSettings(userId);
}
async updateSettings(params: {userId: UserID; data: UserSettingsUpdateRequest}): Promise<UserSettings> {
return await this.accountService.updateSettings(params);
}
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
return await this.accountService.findGuildSettings(userId, guildId);
}
async updateGuildSettings(params: {
userId: UserID;
guildId: GuildID | null;
data: UserGuildSettingsUpdateRequest;
}): Promise<UserGuildSettings> {
return await this.accountService.updateGuildSettings(params);
}
async getUserNote(params: {userId: UserID; targetId: UserID}): Promise<{note: string} | null> {
return await this.accountService.getUserNote(params);
}
async getUserNotes(userId: UserID): Promise<Record<string, string>> {
return await this.accountService.getUserNotes(userId);
}
async setUserNote(params: {userId: UserID; targetId: UserID; note: string | null}): Promise<void> {
return await this.accountService.setUserNote(params);
}
async selfDisable(userId: UserID): Promise<void> {
return await this.accountService.selfDisable(userId);
}
async selfDelete(userId: UserID): Promise<void> {
return await this.accountService.selfDelete(userId);
}
async resetCurrentUserPremiumState(user: User): Promise<void> {
return await this.accountService.resetCurrentUserPremiumState(user);
}
async dispatchUserUpdate(user: User): Promise<void> {
return await this.accountService.dispatchUserUpdate(user);
}
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
return await this.accountService.dispatchUserSettingsUpdate({userId, settings});
}
async dispatchUserGuildSettingsUpdate({
userId,
settings,
}: {
userId: UserID;
settings: UserGuildSettings;
}): Promise<void> {
return await this.accountService.dispatchUserGuildSettingsUpdate({userId, settings});
}
async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise<void> {
return await this.accountService.dispatchUserNoteUpdate(params);
}
async enableMfaTotp(params: {
user: User;
secret: string;
code: string;
sudoContext: SudoVerificationResult;
}): Promise<Array<MfaBackupCode>> {
return await this.authService.enableMfaTotp(params);
}
async disableMfaTotp(params: {
user: User;
code: string;
sudoContext: SudoVerificationResult;
password?: string;
}): Promise<void> {
return await this.authService.disableMfaTotp(params);
}
async getMfaBackupCodes(params: {
user: User;
regenerate: boolean;
sudoContext: SudoVerificationResult;
password?: string;
}): Promise<Array<MfaBackupCode>> {
return await this.authService.getMfaBackupCodes(params);
}
async regenerateMfaBackupCodes(user: User): Promise<Array<MfaBackupCode>> {
return await this.authService.regenerateMfaBackupCodes(user);
}
async verifyEmail(token: string): Promise<boolean> {
return await this.authService.verifyEmail(token);
}
async resendVerificationEmail(user: User): Promise<boolean> {
return await this.authService.resendVerificationEmail(user);
}
async getRelationships(userId: UserID): Promise<Array<Relationship>> {
return await this.relationshipService.getRelationships(userId);
}
async sendFriendRequestByTag({
userId,
data,
userCacheService,
requestCache,
}: {
userId: UserID;
data: FriendRequestByTagRequest;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
return await this.relationshipService.sendFriendRequestByTag({userId, data, userCacheService, requestCache});
}
async sendFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
return await this.relationshipService.sendFriendRequest({userId, targetId, userCacheService, requestCache});
}
async acceptFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const relationship = await this.relationshipService.acceptFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
});
await this.channelService.ensureDmOpenForBothUsers({
userId,
recipientId: targetId,
userCacheService,
requestCache,
});
return relationship;
}
async blockUser({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
return await this.relationshipService.blockUser({userId, targetId, userCacheService, requestCache});
}
async updateFriendNickname({
userId,
targetId,
nickname,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
nickname: string | null;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
return await this.relationshipService.updateFriendNickname({
userId,
targetId,
nickname,
userCacheService,
requestCache,
});
}
async removeRelationship({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
return await this.relationshipService.removeRelationship({userId, targetId});
}
async dispatchRelationshipCreate({
userId,
relationship,
userCacheService,
requestCache,
}: {
userId: UserID;
relationship: Relationship;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
return await this.relationshipService.dispatchRelationshipCreate({
userId,
relationship,
userCacheService,
requestCache,
});
}
async dispatchRelationshipUpdate({
userId,
relationship,
userCacheService,
requestCache,
}: {
userId: UserID;
relationship: Relationship;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
return await this.relationshipService.dispatchRelationshipUpdate({
userId,
relationship,
userCacheService,
requestCache,
});
}
async dispatchRelationshipRemove({userId, targetId}: {userId: UserID; targetId: string}): Promise<void> {
return await this.relationshipService.dispatchRelationshipRemove({userId, targetId});
}
async getPrivateChannels(userId: UserID): Promise<Array<Channel>> {
return await this.channelService.getPrivateChannels(userId);
}
async createOrOpenDMChannel({
userId,
data,
userCacheService,
requestCache,
}: {
userId: UserID;
data: CreatePrivateChannelRequest;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
return await this.channelService.createOrOpenDMChannel({userId, data, userCacheService, requestCache});
}
async pinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
return await this.channelService.pinDmChannel({userId, channelId});
}
async unpinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
return await this.channelService.unpinDmChannel({userId, channelId});
}
async preloadDMMessages(params: {
userId: UserID;
channelIds: Array<ChannelID>;
}): Promise<Record<string, Message | null>> {
return await this.channelService.preloadDMMessages(params);
}
async getRecentMentions(params: {
userId: UserID;
limit: number;
everyone: boolean;
roles: boolean;
guilds: boolean;
before?: MessageID;
}): Promise<Array<Message>> {
return await this.contentService.getRecentMentions(params);
}
async deleteRecentMention({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
return await this.contentService.deleteRecentMention({userId, messageId});
}
async getSavedMessages({userId, limit}: {userId: UserID; limit: number}): Promise<Array<SavedMessageEntry>> {
return await this.contentService.getSavedMessages({userId, limit});
}
async saveMessage({
userId,
channelId,
messageId,
userCacheService,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
return await this.contentService.saveMessage({userId, channelId, messageId, userCacheService, requestCache});
}
async unsaveMessage({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
return await this.contentService.unsaveMessage({userId, messageId});
}
async registerPushSubscription(params: {
userId: UserID;
endpoint: string;
keys: {p256dh: string; auth: string};
userAgent?: string;
}): Promise<PushSubscription> {
return await this.contentService.registerPushSubscription(params);
}
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
return await this.contentService.listPushSubscriptions(userId);
}
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
return await this.contentService.deletePushSubscription(userId, subscriptionId);
}
async requestDataHarvest(userId: UserID): Promise<{
harvest_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
created_at: string;
}> {
return await this.contentService.requestDataHarvest(userId);
}
async getHarvestStatus(userId: UserID, harvestId: bigint): Promise<UserHarvestResponse> {
return await this.contentService.getHarvestStatus(userId, harvestId);
}
async getLatestHarvest(userId: UserID): Promise<UserHarvestResponse | null> {
return await this.contentService.getLatestHarvest(userId);
}
async getHarvestDownloadUrl(
userId: UserID,
harvestId: bigint,
storageService: IStorageService,
): Promise<{download_url: string; expires_at: string}> {
return await this.contentService.getHarvestDownloadUrl(userId, harvestId, storageService);
}
async requestBulkMessageDeletion(params: {userId: UserID; delayMs?: number}): Promise<void> {
return await this.contentService.requestBulkMessageDeletion(params);
}
async cancelBulkMessageDeletion(userId: UserID): Promise<void> {
return await this.contentService.cancelBulkMessageDeletion(userId);
}
async dispatchRecentMentionDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
return await this.contentService.dispatchRecentMentionDelete({userId, messageId});
}
async dispatchSavedMessageCreate({
userId,
message,
userCacheService,
requestCache,
}: {
userId: UserID;
message: Message;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
return await this.contentService.dispatchSavedMessageCreate({userId, message, userCacheService, requestCache});
}
async dispatchSavedMessageDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
return await this.contentService.dispatchSavedMessageDelete({userId, messageId});
}
}

View File

@@ -0,0 +1,44 @@
/*
* 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 {createTestAccount, loginAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {deleteAccount, expectDataExists} from '@fluxer/api/src/user/tests/UserTestUtils';
import {beforeEach, describe, expect, test} from 'vitest';
describe('Account Delete Auto Cancel on Login', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('logging in after account deletion cancels the deletion', async () => {
const account = await createTestAccount(harness);
await deleteAccount(harness, account.token, account.password);
const login = await loginAccount(harness, account);
expect(login.token).not.toBe('');
const data = await expectDataExists(harness, account.userId);
expect(data.hasSelfDeletedFlag).toBe(false);
expect(data.pendingDeletionAt).toBeNull();
});
});

View File

@@ -0,0 +1,65 @@
/*
* 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 {createTestAccount, loginAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {createGuild} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {deleteAccount, expectDataExists} from '@fluxer/api/src/user/tests/UserTestUtils';
import {beforeEach, describe, expect, test} from 'vitest';
describe('Account Deletion Grace Period', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('account deletion enters grace period and allows login', async () => {
const account = await createTestAccount(harness);
await deleteAccount(harness, account.token, account.password);
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
const dataBeforeLogin = await expectDataExists(harness, account.userId);
expect(dataBeforeLogin.hasSelfDeletedFlag).toBe(true);
expect(dataBeforeLogin.pendingDeletionAt).not.toBeNull();
const loginAfterDelete = await loginAccount(harness, account);
expect(loginAfterDelete.token).not.toBe('');
const dataAfterLogin = await expectDataExists(harness, account.userId);
expect(dataAfterLogin.hasSelfDeletedFlag).toBe(false);
expect(dataAfterLogin.pendingDeletionAt).toBeNull();
});
test('account deletion is blocked while user owns guilds', async () => {
const account = await createTestAccount(harness);
await createGuild(harness, account.token, 'Owned Guild');
await createBuilder(harness, account.token)
.post('/users/@me/delete')
.body({password: account.password})
.expect(HTTP_STATUS.BAD_REQUEST, 'USER_OWNS_GUILDS')
.execute();
});
});

View File

@@ -0,0 +1,113 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
acceptInvite,
createChannel,
createChannelInvite,
createGuild,
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
import {ensureSessionStarted, sendMessage} from '@fluxer/api/src/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {
deleteAccount,
setPendingDeletionAt,
triggerDeletionWorker,
waitForDeletionCompletion,
} from '@fluxer/api/src/user/tests/UserTestUtils';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
describe('Account Delete Message Pagination', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('account deletion anonymizes messages beyond chunk size', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Message Pagination Guild');
let channelId = guild.system_channel_id;
if (!channelId) {
const channel = await createChannel(harness, account.token, guild.id, 'general');
channelId = channel.id;
}
const chunkSize = 100;
const extraMessages = 5;
const totalMessages = chunkSize + extraMessages;
await ensureSessionStarted(harness, account.token);
for (let i = 0; i < totalMessages; i++) {
await sendMessage(harness, account.token, channelId, `Message ${i + 1}`);
}
const newOwner = await createTestAccount(harness);
const invite = await createChannelInvite(harness, account.token, channelId);
await acceptInvite(harness, newOwner.token, invite.code);
await createBuilder(harness, account.token)
.post(`/guilds/${guild.id}/transfer-ownership`)
.body({
new_owner_id: newOwner.userId,
password: account.password,
})
.expect(HTTP_STATUS.OK)
.execute();
await deleteAccount(harness, account.token, account.password);
const past = new Date();
past.setMinutes(past.getMinutes() - 1);
await setPendingDeletionAt(harness, account.userId, past);
await triggerDeletionWorker(harness);
await waitForDeletionCompletion(harness, account.userId);
const messages = await createBuilder<Array<MessageResponse>>(harness, newOwner.token)
.get(`/channels/${channelId}/messages?limit=100`)
.expect(HTTP_STATUS.OK)
.execute();
const anonymizedMessages = messages.filter((message) => message.content.startsWith('Message '));
expect(anonymizedMessages.length).toBeGreaterThan(0);
for (const message of anonymizedMessages) {
expect(message.author.id).not.toBe(account.userId);
expect(message.author.username).toBe('DeletedUser');
expect(message.author.discriminator).toBe('0000');
}
const countJson = await createBuilderWithoutAuth<{count: number}>(harness)
.get(`/test/users/${account.userId}/messages/count`)
.expect(HTTP_STATUS.OK)
.execute();
expect(countJson.count).toBe(0);
}, 60_000);
});

View File

@@ -0,0 +1,58 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {createFriendship} from '@fluxer/api/src/user/tests/RelationshipTestUtils';
import {
deleteAccount,
setPendingDeletionAt,
triggerDeletionWorker,
waitForDeletionCompletion,
} from '@fluxer/api/src/user/tests/UserTestUtils';
import {beforeEach, describe, test} from 'vitest';
describe('Account Delete Permanent', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('permanent account deletion removes user data', async () => {
const account = await createTestAccount(harness);
const friend = await createTestAccount(harness);
await createFriendship(harness, account, friend);
await deleteAccount(harness, account.token, account.password);
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
const past = new Date();
past.setMinutes(past.getMinutes() - 1);
await setPendingDeletionAt(harness, account.userId, past);
await triggerDeletionWorker(harness);
await waitForDeletionCompletion(harness, account.userId);
});
});

View File

@@ -0,0 +1,48 @@
/*
* 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 {createTestAccount, loginAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {disableAccount, expectDataExists} from '@fluxer/api/src/user/tests/UserTestUtils';
import {beforeEach, describe, expect, test} from 'vitest';
describe('Account Disable', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('disabling account preserves data and allows re-login', async () => {
const account = await createTestAccount(harness);
await disableAccount(harness, account.token, account.password);
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
const login = await loginAccount(harness, account);
expect(login.token).not.toBe('');
const data = await expectDataExists(harness, account.userId);
expect(data.emailCleared).toBe(false);
expect(data.passwordCleared).toBe(false);
});
});

View File

@@ -0,0 +1,291 @@
/*
* 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 {
createTestAccountForAttachmentTests,
setupTestGuildAndChannel,
} from '@fluxer/api/src/channel/tests/AttachmentTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {
createFavoriteMemeFromMessage,
createMessageWithImageAttachment,
listFavoriteMemes,
} from '@fluxer/api/src/user/tests/FavoriteMemeTestUtils';
import {MAX_FAVORITE_MEME_TAGS, MAX_FAVORITE_MEMES_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
describe('Favorite Meme Limits', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('should enforce maximum favorite memes limit for non-premium users', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
for (let i = 0; i < MAX_FAVORITE_MEMES_NON_PREMIUM; i++) {
const message = await createMessageWithImageAttachment(
harness,
account.token,
channel.id,
i % 2 === 0 ? 'yeah.png' : 'thisisfine.gif',
);
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: `Meme ${i + 1}`,
});
}
const memes = await listFavoriteMemes(harness, account.token);
expect(memes.length).toBe(MAX_FAVORITE_MEMES_NON_PREMIUM);
const extraMessage = await createMessageWithImageAttachment(harness, account.token, channel.id);
await createBuilder(harness, account.token)
.post(`/channels/${channel.id}/messages/${extraMessage.id}/memes`)
.body({
attachment_id: extraMessage.attachments[0].id,
name: 'One Too Many',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
}, 10000);
test('should enforce maximum tags per meme limit', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const tooManyTags = Array.from({length: MAX_FAVORITE_MEME_TAGS + 1}, (_, i) => `tag${i + 1}`);
await createBuilder(harness, account.token)
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
.body({
attachment_id: message.attachments[0].id,
name: 'Too Many Tags',
tags: tooManyTags,
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should allow exactly max tags per meme', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const maxTags = Array.from({length: MAX_FAVORITE_MEME_TAGS}, (_, i) => `tag${i + 1}`);
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Max Tags Meme',
tags: maxTags,
});
expect(meme.tags.length).toBe(MAX_FAVORITE_MEME_TAGS);
});
test('should enforce tag limit on update', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Initial Tags',
tags: ['tag1', 'tag2'],
});
const tooManyTags = Array.from({length: MAX_FAVORITE_MEME_TAGS + 1}, (_, i) => `newtag${i + 1}`);
await createBuilder(harness, account.token)
.patch(`/users/@me/memes/${meme.id}`)
.body({
tags: tooManyTags,
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject saving same attachment twice', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Original',
});
await createBuilder(harness, account.token)
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
.body({
attachment_id: message.attachments[0].id,
name: 'Duplicate',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should allow different media content', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message1 = await createMessageWithImageAttachment(harness, account.token, channel.id, 'yeah.png');
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message1.id, {
attachment_id: message1.attachments[0].id,
name: 'PNG Meme',
});
const message2 = await createMessageWithImageAttachment(harness, account.token, channel.id, 'thisisfine.gif');
const meme2 = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message2.id, {
attachment_id: message2.attachments[0].id,
name: 'GIF Meme',
});
expect(meme2.id).toBeTruthy();
expect(meme2.filename).toBe('thisisfine.gif');
});
test('should return error for invalid attachment id', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
await createBuilder(harness, account.token)
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
.body({
attachment_id: '999999999999999999',
name: 'Invalid Attachment',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should return error for invalid embed index', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
await createBuilder(harness, account.token)
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
.body({
embed_index: 99,
name: 'Invalid Embed',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should return error for message without media', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const {sendMessage} = await import('@fluxer/api/src/message/tests/MessageTestUtils');
const textMessage = await sendMessage(harness, account.token, channel.id, 'No media here');
await createBuilder(harness, account.token)
.post(`/channels/${channel.id}/messages/${textMessage.id}/memes`)
.body({
embed_index: 0,
name: 'No Media',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should return error for unknown message', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
await createBuilder(harness, account.token)
.post(`/channels/${channel.id}/messages/999999999999999999/memes`)
.body({
attachment_id: '123',
name: 'Unknown Message',
})
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
test('should return error for inaccessible channel', async () => {
const account1 = await createTestAccountForAttachmentTests(harness);
const account2 = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account1);
const message = await createMessageWithImageAttachment(harness, account1.token, channel.id);
await createBuilder(harness, account2.token)
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
.body({
attachment_id: message.attachments[0].id,
name: 'Inaccessible',
})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('should not allow accessing other users memes', async () => {
const account1 = await createTestAccountForAttachmentTests(harness);
const account2 = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account1);
const message = await createMessageWithImageAttachment(harness, account1.token, channel.id);
const meme = await createFavoriteMemeFromMessage(harness, account1.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Private Meme',
});
await createBuilder(harness, account2.token)
.get(`/users/@me/memes/${meme.id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
test('should not allow updating other users memes', async () => {
const account1 = await createTestAccountForAttachmentTests(harness);
const account2 = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account1);
const message = await createMessageWithImageAttachment(harness, account1.token, channel.id);
const meme = await createFavoriteMemeFromMessage(harness, account1.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Private Meme',
});
await createBuilder(harness, account2.token)
.patch(`/users/@me/memes/${meme.id}`)
.body({
name: 'Stolen Meme',
})
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
});

View File

@@ -0,0 +1,358 @@
/*
* 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 {
createTestAccountForAttachmentTests,
setupTestGuildAndChannel,
} from '@fluxer/api/src/channel/tests/AttachmentTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {
createFavoriteMemeFromMessage,
createMessageWithImageAttachment,
deleteFavoriteMeme,
getFavoriteMeme,
listFavoriteMemes,
updateFavoriteMeme,
} from '@fluxer/api/src/user/tests/FavoriteMemeTestUtils';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
describe('Favorite Meme Operations', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('should create favorite meme from message attachment', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'My Favorite Meme',
});
expect(meme.id).toBeTruthy();
expect(meme.name).toBe('My Favorite Meme');
expect(meme.user_id).toBe(account.userId);
expect(meme.filename).toBe('yeah.png');
expect(meme.content_type).toBe('image/png');
});
test('should create favorite meme with alt text and tags', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Tagged Meme',
alt_text: 'A funny image',
tags: ['funny', 'reaction'],
});
expect(meme.name).toBe('Tagged Meme');
expect(meme.alt_text).toBe('A funny image');
expect(meme.tags).toEqual(['funny', 'reaction']);
});
test('should list favorite memes', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message1 = await createMessageWithImageAttachment(harness, account.token, channel.id);
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message1.id, {
attachment_id: message1.attachments[0].id,
name: 'First Meme',
});
const message2 = await createMessageWithImageAttachment(harness, account.token, channel.id, 'thisisfine.gif');
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message2.id, {
attachment_id: message2.attachments[0].id,
name: 'Second Meme',
});
const memes = await listFavoriteMemes(harness, account.token);
expect(memes.length).toBe(2);
expect(memes.some((m) => m.name === 'First Meme')).toBe(true);
expect(memes.some((m) => m.name === 'Second Meme')).toBe(true);
});
test('should get single favorite meme by id', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Get Me',
});
const meme = await getFavoriteMeme(harness, account.token, created.id);
expect(meme.id).toBe(created.id);
expect(meme.name).toBe('Get Me');
});
test('should update favorite meme name', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Original Name',
});
const updated = await updateFavoriteMeme(harness, account.token, created.id, {
name: 'New Name',
});
expect(updated.name).toBe('New Name');
});
test('should update favorite meme alt text', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Meme Name',
});
const updated = await updateFavoriteMeme(harness, account.token, created.id, {
alt_text: 'Updated description',
});
expect(updated.alt_text).toBe('Updated description');
});
test('should clear favorite meme alt text by setting to null', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Meme',
alt_text: 'Has alt text',
});
expect(created.alt_text).toBe('Has alt text');
const updated = await updateFavoriteMeme(harness, account.token, created.id, {
alt_text: null,
});
expect(updated.alt_text).toBeNull();
});
test('should update favorite meme tags', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Tagged',
tags: ['old-tag'],
});
const updated = await updateFavoriteMeme(harness, account.token, created.id, {
tags: ['new-tag', 'another-tag'],
});
expect(updated.tags).toEqual(['new-tag', 'another-tag']);
});
test('should delete favorite meme', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Delete Me',
});
await deleteFavoriteMeme(harness, account.token, created.id);
const memes = await listFavoriteMemes(harness, account.token);
expect(memes.find((m) => m.id === created.id)).toBeUndefined();
});
test('should return 404 for unknown meme id', async () => {
const account = await createTestAccountForAttachmentTests(harness);
await createBuilder(harness, account.token)
.get('/users/@me/memes/999999999999999999')
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
test('should require name when creating from message', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
await createBuilder(harness, account.token)
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
.body({
attachment_id: message.attachments[0].id,
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should require either attachment_id or embed_index', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
await createBuilder(harness, account.token)
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
.body({
name: 'Test Meme',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should validate name length max 100 characters', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
await createBuilder(harness, account.token)
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
.body({
attachment_id: message.attachments[0].id,
name: 'a'.repeat(101),
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should validate alt_text length max 500 characters', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
await createBuilder(harness, account.token)
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
.body({
attachment_id: message.attachments[0].id,
name: 'Valid Name',
alt_text: 'a'.repeat(501),
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should validate tag length max 30 characters', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
await createBuilder(harness, account.token)
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
.body({
attachment_id: message.attachments[0].id,
name: 'Valid Name',
tags: ['a'.repeat(31)],
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject empty tags', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
await createBuilder(harness, account.token)
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
.body({
attachment_id: message.attachments[0].id,
name: 'Tagged Meme',
tags: ['valid', '', ' ', 'another'],
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should delete idempotently', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Delete Twice',
});
await deleteFavoriteMeme(harness, account.token, created.id);
await createBuilder(harness, account.token)
.delete(`/users/@me/memes/${created.id}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
test('should return empty list when no memes exist', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const memes = await listFavoriteMemes(harness, account.token);
expect(memes).toEqual([]);
});
test('should include url in meme response', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'URL Test',
});
expect(meme.url).toBeTruthy();
expect(meme.url).toContain(meme.attachment_id);
expect(meme.url).toContain(meme.filename);
});
});

View File

@@ -0,0 +1,135 @@
/*
* 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 {loadFixture, sendMessageWithAttachments} from '@fluxer/api/src/channel/tests/AttachmentTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import type {FavoriteMemeResponse} from '@fluxer/schema/src/domains/meme/MemeSchemas';
export interface MessageWithAttachment {
id: string;
channel_id: string;
attachments: Array<{
id: string;
filename: string;
size: number;
url?: string | null;
}>;
}
export async function listFavoriteMemes(harness: ApiTestHarness, token: string): Promise<Array<FavoriteMemeResponse>> {
return createBuilder<Array<FavoriteMemeResponse>>(harness, token).get('/users/@me/memes').execute();
}
export async function getFavoriteMeme(
harness: ApiTestHarness,
token: string,
memeId: string,
): Promise<FavoriteMemeResponse> {
return createBuilder<FavoriteMemeResponse>(harness, token).get(`/users/@me/memes/${memeId}`).execute();
}
export async function createFavoriteMemeFromMessage(
harness: ApiTestHarness,
token: string,
channelId: string,
messageId: string,
data: {
attachment_id?: string;
embed_index?: number;
name: string;
alt_text?: string;
tags?: Array<string>;
},
): Promise<FavoriteMemeResponse> {
return createBuilder<FavoriteMemeResponse>(harness, token)
.post(`/channels/${channelId}/messages/${messageId}/memes`)
.body(data)
.expect(HTTP_STATUS.CREATED)
.execute();
}
export async function createFavoriteMemeFromUrl(
harness: ApiTestHarness,
token: string,
data: {
url: string;
name?: string;
alt_text?: string;
tags?: Array<string>;
klipy_slug?: string;
},
): Promise<FavoriteMemeResponse> {
return createBuilder<FavoriteMemeResponse>(harness, token)
.post('/users/@me/memes')
.body(data)
.expect(HTTP_STATUS.CREATED)
.execute();
}
export async function updateFavoriteMeme(
harness: ApiTestHarness,
token: string,
memeId: string,
data: {
name?: string;
alt_text?: string | null;
tags?: Array<string>;
},
): Promise<FavoriteMemeResponse> {
return createBuilder<FavoriteMemeResponse>(harness, token).patch(`/users/@me/memes/${memeId}`).body(data).execute();
}
export async function deleteFavoriteMeme(harness: ApiTestHarness, token: string, memeId: string): Promise<void> {
await createBuilder<void>(harness, token)
.delete(`/users/@me/memes/${memeId}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
}
export async function createMessageWithImageAttachment(
harness: ApiTestHarness,
token: string,
channelId: string,
filename = 'yeah.png',
): Promise<MessageWithAttachment> {
const fileData = loadFixture(filename);
const {response, json} = await sendMessageWithAttachments(
harness,
token,
channelId,
{
content: 'Test message with attachment',
attachments: [{id: 0, filename}],
},
[{index: 0, filename, data: fileData}],
);
if (response.status !== 200) {
throw new Error(`Failed to create message with attachment: ${response.status}`);
}
return {
id: json.id,
channel_id: channelId,
attachments: json.attachments ?? [],
};
}

View File

@@ -0,0 +1,62 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {
expectHarvestDownloadFailsWithError,
fetchHarvestDownload,
markHarvestCompleted,
requestHarvest,
} from '@fluxer/api/src/user/tests/HarvestTestUtils';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
describe('Harvest Download Expiration Boundary', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
vi.useFakeTimers();
});
afterEach(async () => {
vi.useRealTimers();
await harness?.shutdown();
});
test('download succeeds before expiration and fails after', async () => {
vi.useRealTimers();
const account = await createTestAccount(harness);
const {harvest_id} = await requestHarvest(harness, account.token);
const baseTime = Date.now();
const expirationTime = new Date(baseTime + 5000);
await markHarvestCompleted(account.userId, harvest_id, expirationTime);
const download = await fetchHarvestDownload(harness, account.token, harvest_id);
expect(download.download_url).not.toBe('');
expect(download.expires_at).not.toBe('');
vi.useFakeTimers({now: baseTime + 7000});
await expectHarvestDownloadFailsWithError(harness, account.token, harvest_id, 'HARVEST_EXPIRED');
});
});

View File

@@ -0,0 +1,46 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {
expectHarvestDownloadFailsWithError,
markHarvestCompleted,
requestHarvest,
} from '@fluxer/api/src/user/tests/HarvestTestUtils';
import {beforeEach, describe, test} from 'vitest';
describe('Harvest Download Expired', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('download fails when harvest has expired', async () => {
const account = await createTestAccount(harness);
const {harvest_id} = await requestHarvest(harness, account.token);
const expiredTime = new Date(Date.now() - 60 * 60 * 1000);
await markHarvestCompleted(account.userId, harvest_id, expiredTime);
await expectHarvestDownloadFailsWithError(harness, account.token, harvest_id, 'HARVEST_EXPIRED');
});
});

View File

@@ -0,0 +1,44 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {fetchHarvestDownload, markHarvestCompleted, requestHarvest} from '@fluxer/api/src/user/tests/HarvestTestUtils';
import {beforeEach, describe, expect, test} from 'vitest';
describe('Harvest Download Not Expired', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('download succeeds when harvest has not expired', async () => {
const account = await createTestAccount(harness);
const {harvest_id} = await requestHarvest(harness, account.token);
const validTime = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000);
await markHarvestCompleted(account.userId, harvest_id, validTime);
const download = await fetchHarvestDownload(harness, account.token, harvest_id);
expect(download.download_url).not.toBe('');
expect(download.expires_at).not.toBe('');
});
});

View File

@@ -0,0 +1,111 @@
/*
* 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 {createUserID} from '@fluxer/api/src/BrandedTypes';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {UserHarvestRepository} from '@fluxer/api/src/user/UserHarvestRepository';
import type {
HarvestDownloadUrlResponse,
HarvestStatusResponseSchema,
} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
import {expect} from 'vitest';
import type {z} from 'zod';
export type HarvestStatusResponse = z.infer<typeof HarvestStatusResponseSchema>;
export interface HarvestRequestResponse {
harvest_id: string;
}
export async function requestHarvest(harness: ApiTestHarness, token: string): Promise<HarvestRequestResponse> {
return createBuilder<HarvestRequestResponse>(harness, token).post('/users/@me/harvest').execute();
}
export async function fetchHarvestStatus(
harness: ApiTestHarness,
token: string,
harvestId: string,
): Promise<HarvestStatusResponse> {
return createBuilder<HarvestStatusResponse>(harness, token).get(`/users/@me/harvest/${harvestId}`).execute();
}
export async function fetchHarvestDownload(
harness: ApiTestHarness,
token: string,
harvestId: string,
): Promise<HarvestDownloadUrlResponse> {
return createBuilder<HarvestDownloadUrlResponse>(harness, token)
.get(`/users/@me/harvest/${harvestId}/download`)
.execute();
}
export async function setHarvestExpiration(
harness: ApiTestHarness,
userId: string,
harvestId: string,
expiresAt: string,
): Promise<void> {
await createBuilderWithoutAuth<void>(harness)
.post(`/test/users/${userId}/harvest/${harvestId}/set-expiration`)
.body({expires_at: expiresAt})
.execute();
}
export async function waitForHarvestCompletion(
harness: ApiTestHarness,
token: string,
harvestId: string,
timeoutMs = 90000,
pollIntervalMs = 1000,
): Promise<HarvestStatusResponse> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const status = await fetchHarvestStatus(harness, token, harvestId);
if (status.completed_at !== null && status.download_url_expires_at !== null) {
return status;
}
if (status.failed_at !== null) {
throw new Error(`Harvest failed: ${status.error_message ?? 'unknown error'}`);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error('Harvest did not complete within timeout');
}
export async function expectHarvestDownloadFailsWithError(
harness: ApiTestHarness,
token: string,
harvestId: string,
expectedCode: string,
): Promise<void> {
const {json} = await createBuilder<Record<string, unknown>>(harness, token)
.get(`/users/@me/harvest/${harvestId}/download`)
.expect(400)
.executeWithResponse();
const errorResponse = json as {code: string; message: string};
expect(errorResponse.code).toBe(expectedCode);
}
export async function markHarvestCompleted(userId: string, harvestId: string, expiresAt: Date): Promise<void> {
const harvestRepository = new UserHarvestRepository();
const userIdTyped = createUserID(BigInt(userId));
const harvestIdTyped = BigInt(harvestId);
await harvestRepository.markAsCompleted(userIdTyped, harvestIdTyped, `test/${harvestId}.zip`, 1024n, expiresAt);
}

View File

@@ -0,0 +1,206 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {expectDataExists} from '@fluxer/api/src/user/tests/UserTestUtils';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface InactivityCheckResult {
warnings_sent: number;
deletions_scheduled: number;
errors: number;
}
async function setUserActivity(harness: ApiTestHarness, userId: string, date: Date): Promise<void> {
await createBuilderWithoutAuth(harness)
.post(`/test/users/${userId}/set-last-active-at`)
.body({timestamp: date.toISOString()})
.execute();
}
async function setBotFlag(harness: ApiTestHarness, userId: string, isBot: boolean): Promise<void> {
await createBuilderWithoutAuth(harness).post(`/test/users/${userId}/set-bot-flag`).body({is_bot: isBot}).execute();
}
async function setSystemFlag(harness: ApiTestHarness, userId: string, isSystem: boolean): Promise<void> {
await createBuilderWithoutAuth(harness)
.post(`/test/users/${userId}/set-system-flag`)
.body({is_system: isSystem})
.execute();
}
async function processInactivityDeletions(harness: ApiTestHarness): Promise<InactivityCheckResult> {
return createBuilderWithoutAuth<InactivityCheckResult>(harness)
.post('/test/worker/process-inactivity-deletions')
.body({})
.execute();
}
describe('Inactivity Deletion', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('bot user should never be scheduled for inactivity deletion', async () => {
const account = await createTestAccount(harness);
await setBotFlag(harness, account.userId, true);
const threeYearsAgo = new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000);
await setUserActivity(harness, account.userId, threeYearsAgo);
const result = await processInactivityDeletions(harness);
expect(result.deletions_scheduled).toBe(0);
expect(result.warnings_sent).toBe(0);
const dataStatus = await expectDataExists(harness, account.userId);
expect(dataStatus.userExists).toBe(true);
expect(dataStatus.hasSelfDeletedFlag).toBe(false);
});
test('system user should never be scheduled for inactivity deletion', async () => {
const account = await createTestAccount(harness);
await setSystemFlag(harness, account.userId, true);
const threeYearsAgo = new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000);
await setUserActivity(harness, account.userId, threeYearsAgo);
const result = await processInactivityDeletions(harness);
expect(result.deletions_scheduled).toBe(0);
expect(result.warnings_sent).toBe(0);
const dataStatus = await expectDataExists(harness, account.userId);
expect(dataStatus.userExists).toBe(true);
expect(dataStatus.hasSelfDeletedFlag).toBe(false);
});
test('recently active user should not receive warning', async () => {
const account = await createTestAccount(harness);
const oneMonthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
await setUserActivity(harness, account.userId, oneMonthAgo);
const result = await processInactivityDeletions(harness);
expect(result.warnings_sent).toBe(0);
expect(result.deletions_scheduled).toBe(0);
});
test('inactive user should receive warning email', async () => {
const account = await createTestAccount(harness);
const threeYearsAgo = new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000);
await setUserActivity(harness, account.userId, threeYearsAgo);
const result = await processInactivityDeletions(harness);
expect(result.warnings_sent).toBeGreaterThanOrEqual(0);
});
test('warning email should be idempotent', async () => {
const account = await createTestAccount(harness);
const threeYearsAgo = new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000);
await setUserActivity(harness, account.userId, threeYearsAgo);
const firstResult = await processInactivityDeletions(harness);
const firstWarnings = firstResult.warnings_sent;
const secondResult = await processInactivityDeletions(harness);
expect(secondResult.warnings_sent).toBeLessThanOrEqual(firstWarnings);
});
test('user without activity data should not be deleted', async () => {
const account = await createTestAccount(harness);
const result = await processInactivityDeletions(harness);
expect(result.deletions_scheduled).toBe(0);
const dataStatus = await expectDataExists(harness, account.userId);
expect(dataStatus.userExists).toBe(true);
});
test('user already pending deletion should be skipped', async () => {
const account = await createTestAccount(harness);
const threeYearsAgo = new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000);
await setUserActivity(harness, account.userId, threeYearsAgo);
const pendingDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/set-pending-deletion`)
.body({pending_deletion_at: pendingDate.toISOString()})
.execute();
const result = await processInactivityDeletions(harness);
expect(result.deletions_scheduled).toBe(0);
const dataStatus = await expectDataExists(harness, account.userId);
expect(dataStatus.userExists).toBe(true);
});
test('processing should handle multiple users', async () => {
const account1 = await createTestAccount(harness);
const account2 = await createTestAccount(harness);
const account3 = await createTestAccount(harness);
await setBotFlag(harness, account1.userId, true);
await setSystemFlag(harness, account2.userId, true);
const threeYearsAgo = new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000);
await setUserActivity(harness, account1.userId, threeYearsAgo);
await setUserActivity(harness, account2.userId, threeYearsAgo);
await setUserActivity(harness, account3.userId, threeYearsAgo);
const result = await processInactivityDeletions(harness);
expect(result.errors).toBe(0);
const data1 = await expectDataExists(harness, account1.userId);
const data2 = await expectDataExists(harness, account2.userId);
expect(data1.hasSelfDeletedFlag).toBe(false);
expect(data2.hasSelfDeletedFlag).toBe(false);
});
test('should return processing statistics', async () => {
const result = await processInactivityDeletions(harness);
expect(result).toHaveProperty('warnings_sent');
expect(result).toHaveProperty('deletions_scheduled');
expect(result).toHaveProperty('errors');
expect(typeof result.warnings_sent).toBe('number');
expect(typeof result.deletions_scheduled).toBe('number');
expect(typeof result.errors).toBe('number');
});
});

View File

@@ -0,0 +1,385 @@
/*
* 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 {
createTestAccount,
findLastTestEmail,
listTestEmails,
loginUser,
type TestAccount,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS, TEST_CREDENTIALS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
interface PasswordChangeStartResult {
ticket: string;
code_expires_at: string;
resend_available_at: string | null;
}
interface PasswordChangeVerifyResult {
verification_proof: string;
}
async function startPasswordChange(harness: ApiTestHarness, token: string): Promise<PasswordChangeStartResult> {
return createBuilder<PasswordChangeStartResult>(harness, token)
.post('/users/@me/password-change/start')
.body({})
.execute();
}
async function verifyPasswordChangeCode(
harness: ApiTestHarness,
token: string,
ticket: string,
code: string,
): Promise<PasswordChangeVerifyResult> {
return createBuilder<PasswordChangeVerifyResult>(harness, token)
.post('/users/@me/password-change/verify')
.body({ticket, code})
.execute();
}
async function completePasswordChange(
harness: ApiTestHarness,
token: string,
ticket: string,
verificationProof: string,
newPassword: string,
): Promise<void> {
await createBuilder(harness, token)
.post('/users/@me/password-change/complete')
.body({ticket, verification_proof: verificationProof, new_password: newPassword})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
}
async function getVerificationCode(harness: ApiTestHarness, email: string): Promise<string> {
const emails = await listTestEmails(harness, {recipient: email});
const record = findLastTestEmail(emails, 'password_change_verification');
if (!record) {
throw new Error(`No password change verification email found for ${email}`);
}
return record.metadata.code;
}
async function runFullVerification(
harness: ApiTestHarness,
account: TestAccount,
): Promise<{ticket: string; verificationProof: string}> {
const startResult = await startPasswordChange(harness, account.token);
const code = await getVerificationCode(harness, account.email);
const verifyResult = await verifyPasswordChangeCode(harness, account.token, startResult.ticket, code);
return {ticket: startResult.ticket, verificationProof: verifyResult.verification_proof};
}
describe('PasswordChangeFlow', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
describe('start', () => {
test('sends verification email and returns ticket', async () => {
const account = await createTestAccount(harness);
const result = await startPasswordChange(harness, account.token);
expect(result.ticket).toBeDefined();
expect(typeof result.ticket).toBe('string');
expect(result.code_expires_at).toBeDefined();
expect(result.resend_available_at).toBeDefined();
const emails = await listTestEmails(harness, {recipient: account.email});
const verificationEmail = findLastTestEmail(emails, 'password_change_verification');
expect(verificationEmail).not.toBeNull();
expect(verificationEmail!.metadata.code).toBeDefined();
});
test('requires authentication', async () => {
await createBuilderWithoutAuth(harness)
.post('/users/@me/password-change/start')
.body({})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});
describe('resend', () => {
test('rejects during cooldown period', async () => {
const account = await createTestAccount(harness);
const startResult = await startPasswordChange(harness, account.token);
await createBuilder(harness, account.token)
.post('/users/@me/password-change/resend')
.body({ticket: startResult.ticket})
.expect(429)
.execute();
});
test('rejects with invalid ticket', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/password-change/resend')
.body({ticket: 'invalid-ticket-id'})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
});
test('requires authentication', async () => {
await createBuilderWithoutAuth(harness)
.post('/users/@me/password-change/resend')
.body({ticket: 'some-ticket'})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});
describe('verify', () => {
test('returns verification_proof with correct code', async () => {
const account = await createTestAccount(harness);
const startResult = await startPasswordChange(harness, account.token);
const code = await getVerificationCode(harness, account.email);
const verifyResult = await verifyPasswordChangeCode(harness, account.token, startResult.ticket, code);
expect(verifyResult.verification_proof).toBeDefined();
expect(typeof verifyResult.verification_proof).toBe('string');
});
test('rejects with incorrect code', async () => {
const account = await createTestAccount(harness);
const startResult = await startPasswordChange(harness, account.token);
const {json} = await createBuilder(harness, account.token)
.post('/users/@me/password-change/verify')
.body({ticket: startResult.ticket, code: 'XXXX-YYYY'})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.executeWithResponse();
const body = json as {errors?: Array<{code: string; path: string}>};
expect(body.errors?.[0]?.code).toBe('INVALID_VERIFICATION_CODE');
});
test('rejects with invalid ticket', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/password-change/verify')
.body({ticket: 'nonexistent-ticket', code: 'ABCD-1234'})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
});
test('returns same proof on repeated verification with correct code', async () => {
const account = await createTestAccount(harness);
const startResult = await startPasswordChange(harness, account.token);
const code = await getVerificationCode(harness, account.email);
const firstVerify = await verifyPasswordChangeCode(harness, account.token, startResult.ticket, code);
const secondVerify = await verifyPasswordChangeCode(harness, account.token, startResult.ticket, code);
expect(firstVerify.verification_proof).toBe(secondVerify.verification_proof);
});
test('requires authentication', async () => {
await createBuilderWithoutAuth(harness)
.post('/users/@me/password-change/verify')
.body({ticket: 'some-ticket', code: 'ABCD-1234'})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});
describe('complete', () => {
test('succeeds with correct proof and passwords', async () => {
const account = await createTestAccount(harness);
const {ticket, verificationProof} = await runFullVerification(harness, account);
await completePasswordChange(harness, account.token, ticket, verificationProof, TEST_CREDENTIALS.ALT_PASSWORD_1);
});
test('succeeds when the user does not have a password set', async () => {
const account = await createTestAccount(harness);
const {ticket, verificationProof} = await runFullVerification(harness, account);
await createBuilderWithoutAuth(harness).post(`/test/users/${account.userId}/unclaim`).body({}).execute();
await completePasswordChange(harness, account.token, ticket, verificationProof, TEST_CREDENTIALS.ALT_PASSWORD_1);
});
test('rejects with invalid verification_proof', async () => {
const account = await createTestAccount(harness);
const {ticket} = await runFullVerification(harness, account);
const {json} = await createBuilder(harness, account.token)
.post('/users/@me/password-change/complete')
.body({
ticket,
verification_proof: 'fake-proof-value',
new_password: TEST_CREDENTIALS.ALT_PASSWORD_1,
})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.executeWithResponse();
const body = json as {errors?: Array<{code: string; path: string}>};
expect(body.errors?.[0]?.code).toBe('INVALID_PROOF_TOKEN');
});
test('rejects when ticket has not been verified', async () => {
const account = await createTestAccount(harness);
const startResult = await startPasswordChange(harness, account.token);
const {json} = await createBuilder(harness, account.token)
.post('/users/@me/password-change/complete')
.body({
ticket: startResult.ticket,
verification_proof: 'some-proof',
new_password: TEST_CREDENTIALS.ALT_PASSWORD_1,
})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.executeWithResponse();
const body = json as {errors?: Array<{code: string; path: string}>};
expect(body.errors?.[0]?.code).toBe('INVALID_OR_EXPIRED_TICKET');
});
test('rejects reusing a completed ticket', async () => {
const account = await createTestAccount(harness);
const {ticket, verificationProof} = await runFullVerification(harness, account);
await completePasswordChange(harness, account.token, ticket, verificationProof, TEST_CREDENTIALS.ALT_PASSWORD_1);
const freshLogin = await loginUser(harness, {
email: account.email,
password: TEST_CREDENTIALS.ALT_PASSWORD_1,
});
if ('mfa' in freshLogin) {
throw new Error('Expected non-MFA login');
}
const newToken = (freshLogin as {token: string}).token;
await createBuilder(harness, newToken)
.post('/users/@me/password-change/complete')
.body({
ticket,
verification_proof: verificationProof,
new_password: TEST_CREDENTIALS.ALT_PASSWORD_2,
})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
});
test('invalidates existing sessions after password change', async () => {
const account = await createTestAccount(harness);
const originalToken = account.token;
const {ticket, verificationProof} = await runFullVerification(harness, account);
await completePasswordChange(harness, account.token, ticket, verificationProof, TEST_CREDENTIALS.ALT_PASSWORD_1);
const {response} = await createBuilder(harness, originalToken)
.get('/users/@me')
.expect(HTTP_STATUS.UNAUTHORIZED)
.executeRaw();
expect(response.status).toBe(HTTP_STATUS.UNAUTHORIZED);
});
test('user can log in with new password after change', async () => {
const account = await createTestAccount(harness);
const {ticket, verificationProof} = await runFullVerification(harness, account);
await completePasswordChange(harness, account.token, ticket, verificationProof, TEST_CREDENTIALS.ALT_PASSWORD_1);
const login = await loginUser(harness, {
email: account.email,
password: TEST_CREDENTIALS.ALT_PASSWORD_1,
});
expect('token' in login).toBe(true);
expect('mfa' in login).toBe(false);
});
test('old password no longer works after change', async () => {
const account = await createTestAccount(harness);
const {ticket, verificationProof} = await runFullVerification(harness, account);
await completePasswordChange(harness, account.token, ticket, verificationProof, TEST_CREDENTIALS.ALT_PASSWORD_1);
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email: account.email, password: TEST_CREDENTIALS.STRONG_PASSWORD})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('requires authentication', async () => {
await createBuilderWithoutAuth(harness)
.post('/users/@me/password-change/complete')
.body({
ticket: 'some-ticket',
verification_proof: 'some-proof',
new_password: 'new-password',
})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});
describe('full flow', () => {
test('start, verify, and complete password change end-to-end', async () => {
const account = await createTestAccount(harness);
const startResult = await startPasswordChange(harness, account.token);
expect(startResult.ticket).toBeDefined();
expect(startResult.code_expires_at).toBeDefined();
const code = await getVerificationCode(harness, account.email);
expect(code).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/);
const verifyResult = await verifyPasswordChangeCode(harness, account.token, startResult.ticket, code);
expect(verifyResult.verification_proof).toBeDefined();
await completePasswordChange(
harness,
account.token,
startResult.ticket,
verifyResult.verification_proof,
TEST_CREDENTIALS.ALT_PASSWORD_1,
);
const login = await loginUser(harness, {
email: account.email,
password: TEST_CREDENTIALS.ALT_PASSWORD_1,
});
expect('token' in login).toBe(true);
});
});
});

View File

@@ -0,0 +1,193 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {getGifDataUrl, getPngDataUrl} from '@fluxer/api/src/emoji/tests/EmojiTestUtils';
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {
grantPremium,
type UserProfileUpdateResult,
updateAvatar,
updateBanner,
} from '@fluxer/api/src/user/tests/UserTestUtils';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const PREMIUM_TYPE_SUBSCRIPTION = 2;
const AVATAR_MAX_SIZE = 10 * 1024 * 1024;
interface ValidationErrorResponse {
code: string;
errors?: Array<{path?: string; code?: string}>;
}
function getTooLargeImageDataUrl(): string {
const largeData = 'A'.repeat(AVATAR_MAX_SIZE + 10000);
const base64 = Buffer.from(largeData).toString('base64');
return getPngDataUrl(base64);
}
describe('Profile Image Upload', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
describe('User Avatar', () => {
it('allows uploading valid PNG avatar', async () => {
const account = await createTestAccount(harness);
await ensureSessionStarted(harness, account.token);
const result = await updateAvatar(harness, account.token, getPngDataUrl());
expect(result.avatar).toBeTruthy();
});
it('allows uploading valid GIF avatar with premium', async () => {
const account = await createTestAccount(harness);
await ensureSessionStarted(harness, account.token);
await grantPremium(harness, account.userId, PREMIUM_TYPE_SUBSCRIPTION);
const result = await updateAvatar(harness, account.token, getGifDataUrl());
expect(result.avatar).toBeTruthy();
});
it('rejects animated avatar without premium', async () => {
const account = await createTestAccount(harness);
await ensureSessionStarted(harness, account.token);
await createBuilder<UserProfileUpdateResult>(harness, account.token)
.patch('/users/@me')
.body({avatar: getGifDataUrl()})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
it('rejects avatar that exceeds size limit', async () => {
const account = await createTestAccount(harness);
await ensureSessionStarted(harness, account.token);
await createBuilder<UserProfileUpdateResult>(harness, account.token)
.patch('/users/@me')
.body({avatar: getTooLargeImageDataUrl()})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
it('allows clearing avatar by setting to null', async () => {
const account = await createTestAccount(harness);
await ensureSessionStarted(harness, account.token);
await updateAvatar(harness, account.token, getPngDataUrl());
const cleared = await updateAvatar(harness, account.token, null);
expect(cleared.avatar).toBeNull();
});
it('replaces old avatar when uploading new one', async () => {
const account = await createTestAccount(harness);
await ensureSessionStarted(harness, account.token);
const first = await updateAvatar(harness, account.token, getPngDataUrl());
expect(first.avatar).toBeTruthy();
const firstHash = first.avatar;
const second = await updateAvatar(harness, account.token, getPngDataUrl());
expect(second.avatar).toBeTruthy();
expect(second.avatar).toBe(firstHash);
});
});
describe('User Banner', () => {
it('rejects banner upload without premium', async () => {
const account = await createTestAccount(harness);
await ensureSessionStarted(harness, account.token);
const json = await createBuilder<ValidationErrorResponse>(harness, account.token)
.patch('/users/@me')
.body({banner: getPngDataUrl()})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(json.errors?.[0]?.code).toBe('BANNERS_REQUIRE_PREMIUM');
});
it('allows banner upload with premium', async () => {
const account = await createTestAccount(harness);
await ensureSessionStarted(harness, account.token);
await grantPremium(harness, account.userId, PREMIUM_TYPE_SUBSCRIPTION);
const result = await updateBanner(harness, account.token, getPngDataUrl());
expect(result.banner).toBeTruthy();
});
it('allows uploading GIF banner with premium', async () => {
const account = await createTestAccount(harness);
await ensureSessionStarted(harness, account.token);
await grantPremium(harness, account.userId, PREMIUM_TYPE_SUBSCRIPTION);
const result = await updateBanner(harness, account.token, getGifDataUrl());
expect(result.banner).toBeTruthy();
});
it('rejects banner that exceeds size limit', async () => {
const account = await createTestAccount(harness);
await ensureSessionStarted(harness, account.token);
await grantPremium(harness, account.userId, PREMIUM_TYPE_SUBSCRIPTION);
await createBuilder<UserProfileUpdateResult>(harness, account.token)
.patch('/users/@me')
.body({banner: getTooLargeImageDataUrl()})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
it('allows clearing banner by setting to null', async () => {
const account = await createTestAccount(harness);
await ensureSessionStarted(harness, account.token);
await grantPremium(harness, account.userId, PREMIUM_TYPE_SUBSCRIPTION);
await updateBanner(harness, account.token, getPngDataUrl());
const cleared = await updateBanner(harness, account.token, null);
expect(cleared.banner).toBeNull();
});
});
});

View File

@@ -0,0 +1,195 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {
deletePushSubscription,
listPushSubscriptions,
subscribePush,
} from '@fluxer/api/src/user/tests/UserTestUtils';
import {beforeEach, describe, expect, test} from 'vitest';
describe('Push Subscription Lifecycle', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('subscribe returns a 32-character hex subscription id', async () => {
const account = await createTestAccount(harness);
const result = await subscribePush(harness, account.token, 'https://push.example.com/endpoint-1');
expect(result.subscription_id).toBeDefined();
expect(result.subscription_id).toMatch(/^[a-f0-9]{32}$/);
});
test('same endpoint produces the same subscription id', async () => {
const account = await createTestAccount(harness);
const endpoint = 'https://push.example.com/deterministic';
const first = await subscribePush(harness, account.token, endpoint);
const second = await subscribePush(harness, account.token, endpoint);
expect(first.subscription_id).toBe(second.subscription_id);
});
test('different endpoints produce different subscription ids', async () => {
const account = await createTestAccount(harness);
const first = await subscribePush(harness, account.token, 'https://push.example.com/endpoint-a');
const second = await subscribePush(harness, account.token, 'https://push.example.com/endpoint-b');
expect(first.subscription_id).not.toBe(second.subscription_id);
});
test('list subscriptions returns empty array initially', async () => {
const account = await createTestAccount(harness);
const result = await listPushSubscriptions(harness, account.token);
expect(result.subscriptions).toEqual([]);
});
test('list subscriptions returns registered subscriptions', async () => {
const account = await createTestAccount(harness);
const endpoint = 'https://push.example.com/list-test';
const userAgent = 'TestBrowser/1.0';
const subscribed = await subscribePush(harness, account.token, endpoint, {userAgent});
const result = await listPushSubscriptions(harness, account.token);
expect(result.subscriptions).toHaveLength(1);
expect(result.subscriptions[0].subscription_id).toBe(subscribed.subscription_id);
expect(result.subscriptions[0].user_agent).toBe(userAgent);
});
test('list subscriptions returns multiple subscriptions', async () => {
const account = await createTestAccount(harness);
const first = await subscribePush(harness, account.token, 'https://push.example.com/multi-1');
const second = await subscribePush(harness, account.token, 'https://push.example.com/multi-2');
const result = await listPushSubscriptions(harness, account.token);
expect(result.subscriptions).toHaveLength(2);
const ids = result.subscriptions.map((s) => s.subscription_id).sort();
expect(ids).toContain(first.subscription_id);
expect(ids).toContain(second.subscription_id);
});
test('subscription without user_agent returns null', async () => {
const account = await createTestAccount(harness);
await subscribePush(harness, account.token, 'https://push.example.com/no-ua');
const result = await listPushSubscriptions(harness, account.token);
expect(result.subscriptions).toHaveLength(1);
expect(result.subscriptions[0].user_agent).toBeNull();
});
test('delete subscription removes it from the list', async () => {
const account = await createTestAccount(harness);
const subscribed = await subscribePush(harness, account.token, 'https://push.example.com/delete-test');
await deletePushSubscription(harness, account.token, subscribed.subscription_id);
const result = await listPushSubscriptions(harness, account.token);
expect(result.subscriptions).toHaveLength(0);
});
test('delete one subscription does not affect others', async () => {
const account = await createTestAccount(harness);
const first = await subscribePush(harness, account.token, 'https://push.example.com/keep');
const second = await subscribePush(harness, account.token, 'https://push.example.com/remove');
await deletePushSubscription(harness, account.token, second.subscription_id);
const result = await listPushSubscriptions(harness, account.token);
expect(result.subscriptions).toHaveLength(1);
expect(result.subscriptions[0].subscription_id).toBe(first.subscription_id);
});
test('subscriptions are isolated between users', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
await subscribePush(harness, alice.token, 'https://push.example.com/alice');
await subscribePush(harness, bob.token, 'https://push.example.com/bob');
const aliceSubs = await listPushSubscriptions(harness, alice.token);
const bobSubs = await listPushSubscriptions(harness, bob.token);
expect(aliceSubs.subscriptions).toHaveLength(1);
expect(bobSubs.subscriptions).toHaveLength(1);
expect(aliceSubs.subscriptions[0].subscription_id).not.toBe(bobSubs.subscriptions[0].subscription_id);
});
test('subscribe rejects invalid endpoint', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/push/subscribe')
.body({
endpoint: 'not-a-url',
keys: {p256dh: 'key', auth: 'auth'},
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('subscribe rejects missing keys', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/push/subscribe')
.body({
endpoint: 'https://push.example.com/missing-keys',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('subscribe requires authentication', async () => {
await createBuilder(harness, '')
.post('/users/@me/push/subscribe')
.body({
endpoint: 'https://push.example.com/no-auth',
keys: {p256dh: 'key', auth: 'auth'},
})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
test('list subscriptions requires authentication', async () => {
await createBuilder(harness, '')
.get('/users/@me/push/subscriptions')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
test('delete subscription requires authentication', async () => {
await createBuilder(harness, '')
.delete('/users/@me/push/subscriptions/abc123')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});

View File

@@ -0,0 +1,62 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {
acceptFriendRequest,
assertRelationshipId,
assertRelationshipType,
listRelationships,
sendFriendRequest,
} from '@fluxer/api/src/user/tests/RelationshipTestUtils';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import {beforeEach, describe, expect, test} from 'vitest';
describe('RelationshipAcceptAfterPrivacyChangeAllowsExistingRequest', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('accepting friend request after privacy change allows existing request', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
await sendFriendRequest(harness, alice.token, bob.userId);
await createBuilder(harness, bob.token)
.patch('/users/@me/settings')
.body({friend_source_flags: 3})
.expect(HTTP_STATUS.OK)
.execute();
const {json: accepted} = await acceptFriendRequest(harness, bob.token, alice.userId);
assertRelationshipId(accepted, alice.userId);
assertRelationshipType(accepted, RelationshipTypes.FRIEND);
const {json: aliceRels} = await listRelationships(harness, alice.token);
expect(aliceRels).toHaveLength(1);
assertRelationshipId(aliceRels[0]!, bob.userId);
assertRelationshipType(aliceRels[0]!, RelationshipTypes.FRIEND);
});
});

View File

@@ -0,0 +1,165 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {createDmChannel, createFriendship} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {
acceptFriendRequest,
assertRelationshipId,
assertRelationshipType,
blockUser,
listRelationships,
removeRelationship,
sendFriendRequest,
} from '@fluxer/api/src/user/tests/RelationshipTestUtils';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
describe('RelationshipBlocking', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
test('blocking a user removes existing friendship', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
await createFriendship(harness, alice, bob);
const {json: aliceRelsBeforeBlock} = await listRelationships(harness, alice.token);
expect(aliceRelsBeforeBlock).toHaveLength(1);
assertRelationshipType(aliceRelsBeforeBlock[0]!, RelationshipTypes.FRIEND);
const {json: blocked} = await blockUser(harness, alice.token, bob.userId);
assertRelationshipId(blocked, bob.userId);
assertRelationshipType(blocked, RelationshipTypes.BLOCKED);
const {json: aliceRelsAfterBlock} = await listRelationships(harness, alice.token);
expect(aliceRelsAfterBlock).toHaveLength(1);
assertRelationshipType(aliceRelsAfterBlock[0]!, RelationshipTypes.BLOCKED);
const {json: bobRelsAfterBlock} = await listRelationships(harness, bob.token);
expect(bobRelsAfterBlock).toHaveLength(0);
});
test('blocked user cannot send friend request', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
await blockUser(harness, alice.token, bob.userId);
await createBuilder(harness, bob.token)
.post(`/users/@me/relationships/${alice.userId}`)
.expect(HTTP_STATUS.BAD_REQUEST, 'FRIEND_REQUEST_BLOCKED')
.execute();
});
test('blocking ignores incoming friend requests without notifying sender', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
const {json: outgoing} = await sendFriendRequest(harness, alice.token, bob.userId);
assertRelationshipId(outgoing, bob.userId);
assertRelationshipType(outgoing, RelationshipTypes.OUTGOING_REQUEST);
const {json: blocked} = await blockUser(harness, bob.token, alice.userId);
assertRelationshipId(blocked, alice.userId);
assertRelationshipType(blocked, RelationshipTypes.BLOCKED);
const {json: aliceRels} = await listRelationships(harness, alice.token);
expect(aliceRels).toHaveLength(1);
assertRelationshipType(aliceRels[0]!, RelationshipTypes.OUTGOING_REQUEST);
assertRelationshipId(aliceRels[0]!, bob.userId);
});
test('accept friend request after privacy setting change allows existing request', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
await sendFriendRequest(harness, alice.token, bob.userId);
await createBuilder(harness, bob.token)
.patch('/users/@me/settings')
.body({friend_source_flags: 3})
.expect(HTTP_STATUS.OK)
.execute();
const {json: accepted} = await acceptFriendRequest(harness, bob.token, alice.userId);
assertRelationshipId(accepted, alice.userId);
assertRelationshipType(accepted, RelationshipTypes.FRIEND);
const {json: aliceRels} = await listRelationships(harness, alice.token);
expect(aliceRels).toHaveLength(1);
assertRelationshipId(aliceRels[0]!, bob.userId);
assertRelationshipType(aliceRels[0]!, RelationshipTypes.FRIEND);
});
test('unblock user works correctly', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
const {json: blocked} = await blockUser(harness, alice.token, bob.userId);
assertRelationshipType(blocked, RelationshipTypes.BLOCKED);
const {json: aliceRelsBeforeUnblock} = await listRelationships(harness, alice.token);
expect(aliceRelsBeforeUnblock).toHaveLength(1);
assertRelationshipType(aliceRelsBeforeUnblock[0]!, RelationshipTypes.BLOCKED);
await removeRelationship(harness, alice.token, bob.userId);
const {json: aliceRelsAfterUnblock} = await listRelationships(harness, alice.token);
expect(aliceRelsAfterUnblock).toHaveLength(0);
const {json: outgoing} = await sendFriendRequest(harness, bob.token, alice.userId);
assertRelationshipType(outgoing, RelationshipTypes.OUTGOING_REQUEST);
});
test('cannot send DM to blocked user', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
await ensureSessionStarted(harness, alice.token);
await createFriendship(harness, alice, bob);
const channel = await createDmChannel(harness, alice.token, bob.userId);
await blockUser(harness, alice.token, bob.userId);
await createBuilder(harness, alice.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'hello'})
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
.execute();
});
});

View File

@@ -0,0 +1,109 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {
assertRelationshipId,
assertRelationshipType,
blockUser,
listRelationships,
removeRelationship,
sendFriendRequest,
} from '@fluxer/api/src/user/tests/RelationshipTestUtils';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import {beforeEach, describe, expect, test} from 'vitest';
describe('RelationshipBlockingBehaviors', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
describe('Friend Request Blocking', () => {
test('friend request to blocker is generic blocked error', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
await blockUser(harness, bob.token, alice.userId);
await createBuilder(harness, alice.token)
.post(`/users/@me/relationships/${bob.userId}`)
.expect(HTTP_STATUS.BAD_REQUEST, 'FRIEND_REQUEST_BLOCKED')
.execute();
});
});
describe('Notification Behavior', () => {
test('blocking ignores incoming request without notifying sender', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
const {json: outgoing} = await sendFriendRequest(harness, alice.token, bob.userId);
assertRelationshipId(outgoing, bob.userId);
assertRelationshipType(outgoing, RelationshipTypes.OUTGOING_REQUEST);
const {json: blocked} = await blockUser(harness, bob.token, alice.userId);
assertRelationshipId(blocked, alice.userId);
assertRelationshipType(blocked, RelationshipTypes.BLOCKED);
const {json: aliceRels} = await listRelationships(harness, alice.token);
expect(aliceRels).toHaveLength(1);
expect(aliceRels[0]!.type).toBe(RelationshipTypes.OUTGOING_REQUEST);
assertRelationshipId(aliceRels[0]!, bob.userId);
});
test('ignoring incoming request does not notify sender', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
const {json: outgoing} = await sendFriendRequest(harness, alice.token, bob.userId);
assertRelationshipId(outgoing, bob.userId);
assertRelationshipType(outgoing, RelationshipTypes.OUTGOING_REQUEST);
await removeRelationship(harness, bob.token, alice.userId);
const {json: aliceRels} = await listRelationships(harness, alice.token);
expect(aliceRels).toHaveLength(1);
expect(aliceRels[0]!.type).toBe(RelationshipTypes.OUTGOING_REQUEST);
assertRelationshipId(aliceRels[0]!, bob.userId);
});
});
describe('Outgoing Request Handling', () => {
test('blocking with outgoing request withdraws the request and notifies target', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
const {json: outgoing} = await sendFriendRequest(harness, alice.token, bob.userId);
assertRelationshipId(outgoing, bob.userId);
assertRelationshipType(outgoing, RelationshipTypes.OUTGOING_REQUEST);
const {json: blocked} = await blockUser(harness, alice.token, bob.userId);
assertRelationshipId(blocked, bob.userId);
assertRelationshipType(blocked, RelationshipTypes.BLOCKED);
const {json: bobRels} = await listRelationships(harness, bob.token);
expect(bobRels).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,73 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {
acceptFriendRequest,
listRelationships,
sendFriendRequest,
updateFriendNickname,
} from '@fluxer/api/src/user/tests/RelationshipTestUtils';
import {beforeEach, describe, expect, test} from 'vitest';
describe('RelationshipNicknameUpdate', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('update friend nickname', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
await sendFriendRequest(harness, alice.token, bob.userId);
await acceptFriendRequest(harness, bob.token, alice.userId);
const {json: updated} = await updateFriendNickname(harness, alice.token, bob.userId, 'Bestie Bob');
expect(updated.nickname).toBe('Bestie Bob');
const {json: aliceRels} = await listRelationships(harness, alice.token);
const bobRel = aliceRels.find((r) => r.id === bob.userId);
expect(bobRel?.nickname).toBe('Bestie Bob');
const {json: bobRels} = await listRelationships(harness, bob.token);
const aliceRel = bobRels.find((r) => r.id === alice.userId);
expect(aliceRel?.nickname).toBeNull();
});
test('remove friend nickname', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
await sendFriendRequest(harness, alice.token, bob.userId);
await acceptFriendRequest(harness, bob.token, alice.userId);
await updateFriendNickname(harness, alice.token, bob.userId, 'Bobby');
const {json: updated} = await updateFriendNickname(harness, alice.token, bob.userId, null);
expect(updated.nickname).toBeNull();
const {json: aliceRels} = await listRelationships(harness, alice.token);
const bobRel = aliceRels.find((r) => r.id === bob.userId);
expect(bobRel?.nickname).toBeNull();
});
});

View File

@@ -0,0 +1,138 @@
/*
* 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 {createFriendship as channelCreateFriendship} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import type {RelationshipResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {expect} from 'vitest';
export async function sendFriendRequest(
harness: ApiTestHarness,
token: string,
targetId: string,
): Promise<{response: Response; json: RelationshipResponse}> {
const {response, json} = await createBuilder<RelationshipResponse>(harness, token)
.post(`/users/@me/relationships/${targetId}`)
.executeWithResponse();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}`);
}
return {response, json};
}
export async function sendFriendRequestByTag(
harness: ApiTestHarness,
token: string,
username: string,
discriminator: string,
): Promise<{response: Response; json: RelationshipResponse}> {
const {response, json} = await createBuilder<RelationshipResponse>(harness, token)
.post('/users/@me/relationships')
.body({username, discriminator})
.executeWithResponse();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}`);
}
return {response, json};
}
export async function acceptFriendRequest(
harness: ApiTestHarness,
token: string,
targetId: string,
): Promise<{response: Response; json: RelationshipResponse}> {
const {response, json} = await createBuilder<RelationshipResponse>(harness, token)
.put(`/users/@me/relationships/${targetId}`)
.body({})
.executeWithResponse();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}`);
}
return {response, json};
}
export async function blockUser(
harness: ApiTestHarness,
token: string,
targetId: string,
): Promise<{response: Response; json: RelationshipResponse}> {
const {response, json} = await createBuilder<RelationshipResponse>(harness, token)
.put(`/users/@me/relationships/${targetId}`)
.body({type: RelationshipTypes.BLOCKED})
.executeWithResponse();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}`);
}
return {response, json};
}
export async function removeRelationship(harness: ApiTestHarness, token: string, targetId: string): Promise<void> {
await createBuilder<void>(harness, token).delete(`/users/@me/relationships/${targetId}`).expect(204).execute();
}
export async function listRelationships(
harness: ApiTestHarness,
token: string,
): Promise<{response: Response; json: Array<RelationshipResponse>}> {
const {response, json} = await createBuilder<Array<RelationshipResponse>>(harness, token)
.get('/users/@me/relationships')
.executeWithResponse();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}`);
}
return {response, json};
}
export async function updateFriendNickname(
harness: ApiTestHarness,
token: string,
targetId: string,
nickname: string | null,
): Promise<{response: Response; json: RelationshipResponse}> {
const {response, json} = await createBuilder<RelationshipResponse>(harness, token)
.patch(`/users/@me/relationships/${targetId}`)
.body({nickname})
.executeWithResponse();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}`);
}
return {response, json};
}
export function assertRelationshipType(relationship: RelationshipResponse, expectedType: number): void {
expect(relationship.type).toBe(expectedType);
}
export function assertRelationshipId(relationship: RelationshipResponse, expectedId: string): void {
expect(relationship.id).toBe(expectedId);
}
export function findRelationship(relations: Array<RelationshipResponse>, userId: string): RelationshipResponse | null {
return relations.find((r) => r.id === userId) ?? null;
}
export const createFriendship = channelCreateFriendship;

View File

@@ -0,0 +1,213 @@
/*
* 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 {createTestAccount, createUniqueUsername, unclaimAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
acceptInvite,
createChannelInvite,
createGuild,
getChannel,
sendChannelMessage,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {fetchUserNote, grantPremium, setUserNote} from '@fluxer/api/src/user/tests/UserTestUtils';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {beforeEach, describe, expect, test} from 'vitest';
describe('Unclaimed Account Restrictions', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
describe('Messaging Restrictions', () => {
test('unclaimed account cannot send guild messages', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, owner.token, channel.id);
await acceptInvite(harness, member.token, invite.code);
await unclaimAccount(harness, member.userId);
await createBuilder(harness, member.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'Hello world'})
.expect(HTTP_STATUS.BAD_REQUEST, 'UNCLAIMED_ACCOUNT_CANNOT_SEND_MESSAGES')
.execute();
});
test('unclaimed account can receive DMs', async () => {
const receiver = await createTestAccount(harness);
await unclaimAccount(harness, receiver.userId);
await createBuilder(harness, receiver.token).get('/users/@me').expect(HTTP_STATUS.OK).execute();
});
test('unclaimed account cannot add reactions', async () => {
const owner = await createTestAccount(harness);
const user = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, owner.token, channel.id);
await acceptInvite(harness, user.token, invite.code);
const message = await sendChannelMessage(harness, owner.token, channel.id, 'React to this');
await unclaimAccount(harness, user.userId);
await createBuilder(harness, user.token)
.put(`/channels/${channel.id}/messages/${message.id}/reactions/${encodeURIComponent('👍')}/@me`)
.expect(HTTP_STATUS.BAD_REQUEST, 'UNCLAIMED_ACCOUNT_CANNOT_ADD_REACTIONS')
.execute();
});
});
describe('Friend Request Restrictions', () => {
test('unclaimed account cannot send friend request', async () => {
const unclaimedAccount = await createTestAccount(harness);
const targetAccount = await createTestAccount(harness);
await unclaimAccount(harness, unclaimedAccount.userId);
await createBuilder(harness, unclaimedAccount.token)
.post(`/users/@me/relationships/${targetAccount.userId}`)
.expect(HTTP_STATUS.BAD_REQUEST, 'UNCLAIMED_ACCOUNT_CANNOT_SEND_FRIEND_REQUESTS')
.execute();
});
test('unclaimed account cannot accept friend request', async () => {
const senderAccount = await createTestAccount(harness);
const receiverAccount = await createTestAccount(harness);
await createBuilder(harness, senderAccount.token)
.post(`/users/@me/relationships/${receiverAccount.userId}`)
.execute();
await unclaimAccount(harness, receiverAccount.userId);
await createBuilder(harness, receiverAccount.token)
.put(`/users/@me/relationships/${senderAccount.userId}`)
.body({type: 1})
.expect(HTTP_STATUS.BAD_REQUEST, 'UNCLAIMED_ACCOUNT_CANNOT_ACCEPT_FRIEND_REQUESTS')
.execute();
});
});
describe('Allowed Operations', () => {
test('unclaimed account can use personal notes', async () => {
const user = await createTestAccount(harness);
const target = await createTestAccount(harness);
await unclaimAccount(harness, user.userId);
const noteContent = 'Note from unclaimed account';
await setUserNote(harness, user.token, target.userId, noteContent);
const {json} = await fetchUserNote(harness, user.token, target.userId);
const note = json as {note: string | null};
expect(note.note).toBe(noteContent);
});
test('unclaimed account can change username', async () => {
const account = await createTestAccount(harness);
const newUsername = createUniqueUsername('unclaimed_update');
await unclaimAccount(harness, account.userId);
const updated = await createBuilder<{username: string; discriminator: string}>(harness, account.token)
.patch('/users/@me')
.body({username: newUsername})
.expect(HTTP_STATUS.OK)
.execute();
expect(updated.username).toBe(newUsername);
});
test('unclaimed premium account can change discriminator', async () => {
const account = await createTestAccount(harness);
await grantPremium(harness, account.userId, UserPremiumTypes.SUBSCRIPTION);
await unclaimAccount(harness, account.userId);
const updated = await createBuilder<{username: string; discriminator: string}>(harness, account.token)
.patch('/users/@me')
.body({discriminator: '0067'})
.expect(HTTP_STATUS.OK)
.execute();
expect(updated.discriminator).toBe('0067');
});
test('unclaimed premium account can change username and discriminator together', async () => {
const account = await createTestAccount(harness);
const newUsername = createUniqueUsername('unclaimed_combo');
await grantPremium(harness, account.userId, UserPremiumTypes.SUBSCRIPTION);
await unclaimAccount(harness, account.userId);
const updated = await createBuilder<{username: string; discriminator: string}>(harness, account.token)
.patch('/users/@me')
.body({username: newUsername, discriminator: '0068'})
.expect(HTTP_STATUS.OK)
.execute();
expect(updated.username).toBe(newUsername);
expect(updated.discriminator).toBe('0068');
});
});
describe('Profile Update Restrictions', () => {
test('unclaimed account rejects unsupported profile fields', async () => {
const account = await createTestAccount(harness);
await unclaimAccount(harness, account.userId);
const {response, text} = await createBuilder(harness, account.token)
.patch('/users/@me')
.body({bio: 'not allowed'})
.executeRaw();
expect(response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(text).toContain('UNCLAIMED_ACCOUNTS_CAN_ONLY_SET_EMAIL_VIA_TOKEN');
});
});
describe('Account Deletion', () => {
test('unclaimed account can be deleted without password', async () => {
const account = await createTestAccount(harness);
await unclaimAccount(harness, account.userId);
await createBuilder(harness, account.token)
.post('/users/@me/delete')
.body({})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
});
});

View File

@@ -0,0 +1,130 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS, TEST_IDS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {
checkUsernameDiscriminatorAvailability,
fetchUser,
fetchUserProfile,
preloadMessages,
setUserNote,
updateGuildSettings,
updateUserProfile,
} from '@fluxer/api/src/user/tests/UserTestUtils';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
describe('User Account And Settings', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('user can update profile and settings', async () => {
const account = await createTestAccount(harness);
const newGlobal = `Integration ${Date.now()}`;
const newBio = 'Integration tests ensure user endpoints behave';
const updated = await updateUserProfile(harness, account.token, {
global_name: newGlobal,
bio: newBio,
});
expect(updated.json.global_name).toBe(newGlobal);
expect(updated.json.bio).toBe(newBio);
const checkTagResult = await checkUsernameDiscriminatorAvailability(
harness,
updated.json.username,
updated.json.discriminator,
account.token,
);
expect(checkTagResult.json.taken).toBe(false);
const user = await fetchUser(harness, account.userId, account.token);
expect(user.json.id).toBe(account.userId);
const profile = await fetchUserProfile(harness, account.userId, account.token);
expect(profile.json.user.id).toBe(account.userId);
const guildSettings = await updateGuildSettings(harness, account.token, {
suppress_everyone: true,
});
const settings = guildSettings.json as Record<string, unknown>;
expect(settings.suppress_everyone).toBe(true);
const target = await createTestAccount(harness);
await setUserNote(harness, account.token, target.userId, 'Great tester');
const preload = await preloadMessages(harness, account.token, []);
const preloadData = preload.json as Record<string, unknown>;
expect(Object.keys(preloadData).length).toBe(0);
});
test('reject getting nonexistent user', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.get(`/users/${TEST_IDS.NONEXISTENT_USER}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
test('reject getting nonexistent user profile', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.get(`/users/${TEST_IDS.NONEXISTENT_USER}/profile`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
test('check-tag with missing username returns 400', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.get('/users/check-tag?discriminator=1234')
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('check-tag with missing discriminator returns 400', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.get('/users/check-tag?username=testuser')
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('check-tag with invalid discriminator returns 400', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.get('/users/check-tag?username=test&discriminator=invalid')
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});

View File

@@ -0,0 +1,122 @@
/*
* 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 {
clearTestEmails,
createTestAccount,
findLastTestEmail,
listTestEmails,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, test} from 'vitest';
interface LoginResponse {
user_id: string;
token: string;
}
async function seedAuthorizedIp(params: {
harness: ApiTestHarness;
email: string;
password: string;
ip: string;
}): Promise<void> {
const {harness, email, password, ip} = params;
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', ip)
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.IP_AUTHORIZATION_REQUIRED)
.execute();
const emails = await listTestEmails(harness, {recipient: email});
const ipEmail = findLastTestEmail(emails, 'ip_authorization');
if (!ipEmail?.metadata?.token) {
throw new Error('Missing IP authorization email token');
}
await createBuilderWithoutAuth(harness)
.post('/auth/authorize-ip')
.body({token: ipEmail.metadata.token})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
}
describe('User authorised IPs', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
await clearTestEmails(harness);
});
afterAll(async () => {
await harness?.shutdown();
});
test('requires sudo to forget authorised IPs', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.delete('/users/@me/authorized-ips')
.body({})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.SUDO_MODE_REQUIRED)
.execute();
});
test('forgetting authorised IPs forces email verification on next login', async () => {
const account = await createTestAccount(harness);
const ip = '203.0.113.42';
await seedAuthorizedIp({
harness,
email: account.email,
password: account.password,
ip,
});
const login = await createBuilderWithoutAuth<LoginResponse>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.header('x-forwarded-for', ip)
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, login.token)
.delete('/users/@me/authorized-ips')
.body({password: account.password})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.header('x-forwarded-for', ip)
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.IP_AUTHORIZATION_REQUIRED)
.execute();
});
});

View File

@@ -0,0 +1,449 @@
/*
* 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 {createTestAccount, unclaimAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {authorizeBot, createTestBotAccount} from '@fluxer/api/src/bot/tests/BotTestUtils';
import {
acceptInvite,
blockUser,
createChannelInvite,
createDmChannel,
createFriendship,
createGroupDmChannel,
createGuild,
getChannel,
type MinimalChannelResponse,
sendChannelMessage,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
interface PrivateChannelsResponse extends Array<MinimalChannelResponse> {}
describe('UserChannelService', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
describe('DM channel creation', () => {
test('can create DM with a friend', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
const channel = await createDmChannel(harness, user1.token, user2.userId);
expect(channel.id).toBeDefined();
expect(channel.type).toBe(ChannelTypes.DM);
});
test('bot can create DM when it shares a guild with the recipient', async () => {
const botAccount = await createTestBotAccount(harness);
const recipient = await createTestAccount(harness);
const guild = await createGuild(harness, botAccount.ownerToken, 'Bot DM mutual guild');
const systemChannel = await getChannel(harness, botAccount.ownerToken, guild.system_channel_id!);
const invite = await createChannelInvite(harness, botAccount.ownerToken, systemChannel.id);
await acceptInvite(harness, recipient.token, invite.code);
await authorizeBot(harness, botAccount.ownerToken, botAccount.appId, ['bot'], guild.id, '0');
const channel = await createBuilder<MinimalChannelResponse>(harness, `Bot ${botAccount.botToken}`)
.post('/users/@me/channels')
.body({recipient_id: recipient.userId})
.execute();
expect(channel.id).toBeDefined();
expect(channel.type).toBe(ChannelTypes.DM);
});
test('cannot create DM with yourself', async () => {
const user = await createTestAccount(harness);
await createBuilder(harness, user.token)
.post('/users/@me/channels')
.body({recipient_id: user.userId})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
});
test('cannot create DM with unknown user', async () => {
const user = await createTestAccount(harness);
await createBuilder(harness, user.token)
.post('/users/@me/channels')
.body({recipient_id: '999999999999999999'})
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_USER')
.execute();
});
test('cannot create DM with blocked user', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const guild = await createGuild(harness, user1.token, 'Test Community');
const systemChannel = await getChannel(harness, user1.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, user1.token, systemChannel.id);
await acceptInvite(harness, user2.token, invite.code);
await blockUser(harness, user1, user2.userId);
await createBuilder(harness, user1.token)
.post('/users/@me/channels')
.body({recipient_id: user2.userId})
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
.execute();
});
test('unclaimed account cannot create DM', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const guild = await createGuild(harness, user1.token, 'Test Community');
const systemChannel = await getChannel(harness, user1.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, user1.token, systemChannel.id);
await acceptInvite(harness, user2.token, invite.code);
await unclaimAccount(harness, user1.userId);
await createBuilder(harness, user1.token)
.post('/users/@me/channels')
.body({recipient_id: user2.userId})
.expect(HTTP_STATUS.BAD_REQUEST, 'UNCLAIMED_ACCOUNT_CANNOT_SEND_DIRECT_MESSAGES')
.execute();
});
test('reopening existing DM returns same channel', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
const channel1 = await createDmChannel(harness, user1.token, user2.userId);
const channel2 = await createDmChannel(harness, user1.token, user2.userId);
expect(channel1.id).toBe(channel2.id);
});
test('reopening existing DM works after blocking user', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
const channel1 = await createDmChannel(harness, user1.token, user2.userId);
await blockUser(harness, user1, user2.userId);
const channel2 = await createDmChannel(harness, user1.token, user2.userId);
expect(channel2.id).toBe(channel1.id);
});
});
describe('Group DM creation', () => {
test('can create group DM with friends', async () => {
const owner = await createTestAccount(harness);
const friend1 = await createTestAccount(harness);
const friend2 = await createTestAccount(harness);
await createFriendship(harness, owner, friend1);
await createFriendship(harness, owner, friend2);
const channel = await createGroupDmChannel(harness, owner.token, [friend1.userId, friend2.userId]);
expect(channel.id).toBeDefined();
expect(channel.type).toBe(ChannelTypes.GROUP_DM);
expect(channel.owner_id).toBe(owner.userId);
expect(channel.recipients.length).toBe(2);
});
test('cannot create group DM with non-friend', async () => {
const owner = await createTestAccount(harness);
const friend = await createTestAccount(harness);
const stranger = await createTestAccount(harness);
await createFriendship(harness, owner, friend);
await createBuilder(harness, owner.token)
.post('/users/@me/channels')
.body({recipients: [friend.userId, stranger.userId]})
.expect(HTTP_STATUS.BAD_REQUEST, 'NOT_FRIENDS_WITH_USER')
.execute();
});
test('cannot add yourself to group DM recipients', async () => {
const owner = await createTestAccount(harness);
const friend = await createTestAccount(harness);
await createFriendship(harness, owner, friend);
await createBuilder(harness, owner.token)
.post('/users/@me/channels')
.body({recipients: [friend.userId, owner.userId]})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
});
test('cannot add duplicate recipients to group DM', async () => {
const owner = await createTestAccount(harness);
const friend = await createTestAccount(harness);
await createFriendship(harness, owner, friend);
await createBuilder(harness, owner.token)
.post('/users/@me/channels')
.body({recipients: [friend.userId, friend.userId]})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
});
test('cannot create group DM with unknown user', async () => {
const owner = await createTestAccount(harness);
const friend = await createTestAccount(harness);
await createFriendship(harness, owner, friend);
await createBuilder(harness, owner.token)
.post('/users/@me/channels')
.body({recipients: [friend.userId, '999999999999999999']})
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_USER')
.execute();
});
});
describe('private channel listing', () => {
test('lists all private channels for user', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
await createDmChannel(harness, user1.token, user2.userId);
await createDmChannel(harness, user1.token, user3.userId);
const channels = await createBuilder<PrivateChannelsResponse>(harness, user1.token)
.get('/users/@me/channels')
.execute();
expect(channels.length).toBe(2);
});
});
describe('DM pinning', () => {
test('can pin and unpin DM channel', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
const channel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.put(`/users/@me/channels/${channel.id}/pin`)
.body(null)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, user1.token)
.delete(`/users/@me/channels/${channel.id}/pin`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
test('cannot pin non-DM channel', async () => {
const user = await createTestAccount(harness);
await createBuilder(harness, user.token)
.put('/users/@me/channels/999999999999999999/pin')
.body(null)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
});
describe('preload DM messages', () => {
test('can preload messages for multiple DM channels', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
await ensureSessionStarted(harness, user1.token);
const channel1 = await createDmChannel(harness, user1.token, user2.userId);
const channel2 = await createDmChannel(harness, user1.token, user3.userId);
await sendChannelMessage(harness, user1.token, channel1.id, 'Hello user2');
await sendChannelMessage(harness, user1.token, channel2.id, 'Hello user3');
const result = await createBuilder<Record<string, unknown>>(harness, user1.token)
.post('/users/@me/preload-messages')
.body({channels: [channel1.id, channel2.id]})
.execute();
expect(result).toBeDefined();
});
test('cannot preload more than 100 channels', async () => {
const user = await createTestAccount(harness);
const tooManyChannels = Array.from({length: 101}, (_, i) => i.toString());
await createBuilder(harness, user.token)
.post('/users/@me/preload-messages')
.body({channels: tooManyChannels})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
});
});
describe('sending messages in DMs', () => {
test('can send message in DM to friend', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
await ensureSessionStarted(harness, user1.token);
const channel = await createDmChannel(harness, user1.token, user2.userId);
const message = await sendChannelMessage(harness, user1.token, channel.id, 'Hello!');
expect(message.id).toBeDefined();
expect(message.content).toBe('Hello!');
});
test('cannot send message to user who blocked you', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
await ensureSessionStarted(harness, user1.token);
const channel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user2.token)
.put(`/users/@me/relationships/${user1.userId}`)
.body({type: 2})
.execute();
await createBuilder(harness, user1.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'Hello!'})
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
.execute();
});
});
describe('group DM recipient management', () => {
test('owner can add friend to group DM', async () => {
const owner = await createTestAccount(harness);
const friend1 = await createTestAccount(harness);
const friend2 = await createTestAccount(harness);
await createFriendship(harness, owner, friend1);
await createFriendship(harness, owner, friend2);
const channel = await createGroupDmChannel(harness, owner.token, [friend1.userId]);
await createBuilder(harness, owner.token)
.put(`/channels/${channel.id}/recipients/${friend2.userId}`)
.body(null)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
test('owner can remove recipient from group DM', async () => {
const owner = await createTestAccount(harness);
const friend1 = await createTestAccount(harness);
const friend2 = await createTestAccount(harness);
await createFriendship(harness, owner, friend1);
await createFriendship(harness, owner, friend2);
const channel = await createGroupDmChannel(harness, owner.token, [friend1.userId, friend2.userId]);
await createBuilder(harness, owner.token)
.delete(`/channels/${channel.id}/recipients/${friend2.userId}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
test('cannot add non-friend to group DM', async () => {
const owner = await createTestAccount(harness);
const friend = await createTestAccount(harness);
const stranger = await createTestAccount(harness);
await createFriendship(harness, owner, friend);
const channel = await createGroupDmChannel(harness, owner.token, [friend.userId]);
await createBuilder(harness, owner.token)
.put(`/channels/${channel.id}/recipients/${stranger.userId}`)
.body(null)
.expect(HTTP_STATUS.BAD_REQUEST, 'NOT_FRIENDS_WITH_USER')
.execute();
});
test('non-owner cannot remove other recipients', async () => {
const owner = await createTestAccount(harness);
const member1 = await createTestAccount(harness);
const member2 = await createTestAccount(harness);
await createFriendship(harness, owner, member1);
await createFriendship(harness, owner, member2);
const channel = await createGroupDmChannel(harness, owner.token, [member1.userId, member2.userId]);
await createBuilder(harness, member1.token)
.delete(`/channels/${channel.id}/recipients/${member2.userId}`)
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('member can leave group DM', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
await createFriendship(harness, owner, member);
const channel = await createGroupDmChannel(harness, owner.token, [member.userId]);
await createBuilder(harness, member.token)
.delete(`/channels/${channel.id}/recipients/${member.userId}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
});
});

Some files were not shown because too many files have changed in this diff Show More