initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 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 './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 '~/BrandedTypes';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {User} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {UserPartialResponse} from './UserModel';
import {mapUserToPartialResponse} from './UserModel';
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;
}
const response = mapUserToPartialResponse(user);
requestCache.userPartials.set(user.id, response);
const cacheKey = `user:partial:${user.id}`;
Promise.resolve(userCacheService.cacheService.set(cacheKey, response, 300)).catch(() => {});
return response;
}
export async function invalidateUserCache(params: {userId: UserID; userCacheService: UserCacheService}): Promise<void> {
const {userId, userCacheService} = params;
await userCacheService.invalidateUserCache(userId);
}

View File

@@ -0,0 +1,20 @@
/*
* 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/>.
*/
export {UserController} from './controllers/UserController';

View File

@@ -0,0 +1,96 @@
/*
* 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 '~/BrandedTypes';
import type {UserHarvestRow} from '~/database/CassandraTypes';
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;
requested_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;
} {
return {
harvest_id: this.harvestId.toString(),
requested_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,
};
}
}
export type UserHarvestResponse = ReturnType<UserHarvest['toResponse']>;

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 '~/BrandedTypes';
import {Db, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {UserHarvestRow} from '~/database/CassandraTypes';
import {Logger} from '~/Logger';
import {UserHarvests} from '~/Tables';
import {UserHarvest} from './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,57 @@
/*
* 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 '~/Config';
import {UserFlags} from '~/Constants';
interface PremiumCheckable {
premiumType: number | null;
premiumUntil: Date | null;
premiumWillCancel: boolean;
flags: bigint;
}
const GRACE_MS = 3 * 24 * 60 * 60 * 1000;
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 + GRACE_MS;
}

View File

@@ -0,0 +1,303 @@
/*
* 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 UserID} from '~/BrandedTypes';
import {PUBLIC_USER_FLAGS, SuspiciousActivityFlags, UserFlags} from '~/Constants';
import type {
BetaCode,
GuildChannelOverride,
GuildMember,
MuteConfiguration,
Relationship,
User,
UserGuildSettings,
UserSettings,
} from '~/Models';
import {isUserAdult} from '~/utils/AgeUtils';
import type {
BetaCodeResponse,
RelationshipResponse,
UserGuildSettingsResponse,
UserPartialResponse,
UserPrivateResponse,
UserProfileResponse,
UserSettingsResponse,
} from './UserTypes';
export const mapUserToPartialResponse = (user: User): UserPartialResponse => {
const isBot = user.isBot;
const isPremium = user.isPremium();
let avatarHash = user.avatarHash;
if (avatarHash?.startsWith('a_') && !isPremium && !isBot) {
avatarHash = avatarHash.substring(2);
}
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: Number((user.flags ?? 0n) & PUBLIC_USER_FLAGS),
};
};
export const 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 const mapUserToPrivateResponse = (user: User): UserPrivateResponse => {
const isPremium = user.isPremium();
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;
}
}
return {
...mapUserToPartialResponse(user),
acls: Array.from(user.acls),
email: user.email ?? null,
phone: user.phone ?? null,
bio: user.bio,
pronouns: user.pronouns,
accent_color: user.accentColor,
banner: isPremium ? user.bannerHash : null,
banner_color: isPremium ? user.bannerColor : null,
mfa_enabled: (user.authenticatorTypes?.size ?? 0) > 0,
authenticator_types: user.authenticatorTypes ? Array.from(user.authenticatorTypes) : undefined,
verified: user.emailVerified,
premium_type: isPremium ? user.premiumType : 0,
premium_since: isPremium ? (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),
pending_manual_verification: !!(user.flags & UserFlags.PENDING_MANUAL_VERIFICATION),
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 const mapUserToProfileResponse = (user: User): UserProfileResponse => ({
bio: user.bio,
pronouns: user.pronouns,
banner: user.isPremium() ? user.bannerHash : null,
banner_color: user.isPremium() ? user.bannerColor : null,
accent_color: user.accentColor,
});
export const 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: user.emailVerified ?? false,
email: includeEmail ? user.email : null,
flags: Number((user.flags ?? 0n) & PUBLIC_USER_FLAGS),
public_flags: Number((user.flags ?? 0n) & PUBLIC_USER_FLAGS),
global_name: user.globalName ?? null,
bot: user.isBot || false,
system: user.isSystem || false,
acls: Array.from(user.acls),
};
};
export const 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,
};
};
export const mapUserSettingsToResponse = (params: {settings: UserSettings}): UserSettingsResponse => {
const {settings} = params;
return {
status: settings.status,
status_resets_at: settings.statusResetsAt?.toISOString() ?? null,
status_resets_to: settings.statusResetsTo,
theme: settings.theme,
guild_positions: settings.guildPositions?.map(String) ?? [],
locale: settings.locale,
restricted_guilds: [...settings.restrictedGuilds].map(String),
default_guilds_restricted: settings.defaultGuildsRestricted,
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:
settings.guildFolders?.map((folder) => ({
id: folder.folderId,
name: folder.name,
color: folder.color,
guild_ids: folder.guildIds.map(String),
})) ?? [],
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,
};
};
export const mapRelationshipToResponse = async (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,
};
};
export const mapBetaCodeToResponse = async (params: {
betaCode: BetaCode;
userPartialResolver: (userId: UserID) => Promise<UserPartialResponse>;
}): Promise<BetaCodeResponse> => {
const {betaCode, userPartialResolver} = params;
return {
code: betaCode.code,
created_at: betaCode.createdAt.toISOString(),
redeemed_at: betaCode.redeemedAt?.toISOString() || null,
redeemer: betaCode.redeemerId ? await userPartialResolver(betaCode.redeemerId) : null,
};
};
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;
const mapChannelOverrideToResponse = (
override: GuildChannelOverride,
): {
collapsed: boolean;
message_notifications: number;
muted: boolean;
mute_config: {end_time: string | null; selected_time_window: number} | null;
} => ({
collapsed: override.collapsed,
message_notifications: override.messageNotifications ?? 0,
muted: override.muted,
mute_config: mapMuteConfigToResponse(override.muteConfig),
});
export const mapUserGuildSettingsToResponse = (settings: UserGuildSettings): UserGuildSettingsResponse => ({
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,21 @@
/*
* 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/>.
*/
export * from './UserMappers';
export * from './UserTypes';

View File

@@ -0,0 +1,20 @@
/*
* 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/>.
*/
export {UserRepository} from './repositories/UserRepository';

View File

@@ -0,0 +1,20 @@
/*
* 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/>.
*/
export {UserService} from './services/UserService';

View File

@@ -0,0 +1,390 @@
/*
* 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 emojiRegex from 'emoji-regex';
import {
AVATAR_MAX_SIZE,
Locales,
MAX_GUILDS_PREMIUM,
StatusTypes,
ThemeTypes,
UserNotificationSettings,
} from '~/Constants';
import type {MessageResponse} from '~/channel/ChannelModel';
import {
ColorType,
createBase64StringType,
createStringType,
DateTimeType,
DiscriminatorType,
EmailType,
GlobalNameType,
Int32Type,
Int64Type,
PasswordType,
UsernameType,
z,
} from '~/Schema';
export const UserPartialResponse = z.object({
id: z.string(),
username: z.string(),
discriminator: z.string(),
global_name: z.string().nullish(),
avatar: z.string().nullish(),
avatar_color: z.number().int().nullish(),
bot: z.boolean().optional(),
system: z.boolean().optional(),
flags: z.number().int(),
});
export type UserPartialResponse = z.infer<typeof UserPartialResponse>;
export const UserPrivateResponse = z.object({
...UserPartialResponse.shape,
banner: z.string().nullish(),
banner_color: z.number().int().nullish(),
accent_color: z.number().int().nullish(),
acls: z.array(z.string()),
email: z.string().nullish(),
phone: z.string().nullish(),
bio: z.string().nullish(),
pronouns: z.string().nullish(),
mfa_enabled: z.boolean(),
authenticator_types: z.array(z.number().int()).optional(),
verified: z.boolean(),
premium_type: z.number().int().nullish(),
premium_since: z.iso.datetime().nullish(),
premium_until: z.iso.datetime().nullish(),
premium_will_cancel: z.boolean(),
premium_billing_cycle: z.string().nullish(),
premium_lifetime_sequence: z.number().int().nullish(),
premium_badge_hidden: z.boolean(),
premium_badge_masked: z.boolean(),
premium_badge_timestamp_hidden: z.boolean(),
premium_badge_sequence_hidden: z.boolean(),
premium_purchase_disabled: z.boolean(),
premium_enabled_override: z.boolean(),
password_last_changed_at: z.iso.datetime().nullish(),
required_actions: z.array(z.string()).nullable(),
nsfw_allowed: z.boolean(),
pending_manual_verification: z.boolean(),
has_dismissed_premium_onboarding: z.boolean(),
has_ever_purchased: z.boolean(),
has_unread_gift_inventory: z.boolean(),
unread_gift_inventory_count: z.number().int(),
used_mobile_client: z.boolean(),
pending_bulk_message_deletion: z
.object({
scheduled_at: z.iso.datetime(),
channel_count: z.number().int(),
message_count: z.number().int(),
})
.nullable(),
});
export type UserPrivateResponse = z.infer<typeof UserPrivateResponse>;
export const UserProfileResponse = z.object({
bio: z.string().nullish(),
pronouns: z.string().nullish(),
banner: z.string().nullish(),
banner_color: z.number().int().nullish(),
accent_color: z.number().int().nullish(),
});
export type UserProfileResponse = z.infer<typeof UserProfileResponse>;
export const UserUpdateRequest = z
.object({
username: UsernameType,
discriminator: DiscriminatorType,
global_name: GlobalNameType.nullish(),
email: EmailType,
new_password: PasswordType,
password: PasswordType,
avatar: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
banner: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
bio: createStringType(1, 320).nullish(),
pronouns: createStringType(1, 40).nullish(),
accent_color: ColorType.nullish(),
premium_badge_hidden: z.boolean(),
premium_badge_masked: z.boolean(),
premium_badge_timestamp_hidden: z.boolean(),
premium_badge_sequence_hidden: z.boolean(),
premium_enabled_override: z.boolean(),
has_dismissed_premium_onboarding: z.boolean(),
has_unread_gift_inventory: z.boolean(),
used_mobile_client: z.boolean(),
})
.partial();
export type UserUpdateRequest = z.infer<typeof UserUpdateRequest>;
export type SavedMessageStatus = 'available' | 'missing_permissions';
export interface SavedMessageEntryResponse {
id: string;
channel_id: string;
message_id: string;
status: SavedMessageStatus;
message: MessageResponse | null;
}
const GuildFolderResponse = z.object({
id: z.number().int().nullish(),
name: z.string().nullish(),
color: z.number().int().nullish(),
guild_ids: z.array(z.string()),
});
export const CustomStatusResponse = z.object({
text: z.string().nullish(),
expires_at: z.iso.datetime().nullish(),
emoji_id: z.string().nullish(),
emoji_name: z.string().nullish(),
emoji_animated: z.boolean(),
});
export type CustomStatusResponse = z.infer<typeof CustomStatusResponse>;
const isUnicodeEmoji = (value: string): boolean => {
const regex = emojiRegex();
const match = value.match(regex);
return Boolean(match && match[0] === value);
};
export const CustomStatusPayload = z
.object({
text: createStringType(1, 128).nullish(),
expires_at: DateTimeType.nullish(),
emoji_id: Int64Type.nullish(),
emoji_name: createStringType(1, 32).nullish(),
})
.transform((value) => {
if (value.emoji_id != null) {
return {...value, emoji_name: undefined};
}
return value;
})
.refine((value) => value.emoji_name == null || isUnicodeEmoji(value.emoji_name), {
message: 'Emoji name must be a valid Unicode emoji',
path: ['emoji_name'],
});
export const UserSettingsResponse = z.object({
status: z.string(),
status_resets_at: z.iso.datetime().nullish(),
status_resets_to: z.string().nullish(),
theme: z.string(),
guild_positions: z.array(z.string()),
locale: z.string(),
restricted_guilds: z.array(z.string()),
default_guilds_restricted: z.boolean(),
inline_attachment_media: z.boolean(),
inline_embed_media: z.boolean(),
gif_auto_play: z.boolean(),
render_embeds: z.boolean(),
render_reactions: z.boolean(),
animate_emoji: z.boolean(),
animate_stickers: z.number().int(),
render_spoilers: z.number().int(),
message_display_compact: z.boolean(),
friend_source_flags: z.number().int(),
incoming_call_flags: z.number().int(),
group_dm_add_permission_flags: z.number().int(),
guild_folders: z.array(GuildFolderResponse),
custom_status: CustomStatusResponse.nullish(),
afk_timeout: z.number().int(),
time_format: z.number().int(),
developer_mode: z.boolean(),
});
export type UserSettingsResponse = z.infer<typeof UserSettingsResponse>;
export const UserSettingsUpdateRequest = z
.object({
flags: z.number().int(),
status: z.enum(Object.values(StatusTypes)),
status_resets_at: DateTimeType.nullish(),
status_resets_to: z.enum(Object.values(StatusTypes)).nullish(),
theme: z.enum(Object.values(ThemeTypes)),
guild_positions: z
.array(Int64Type)
.transform((ids) => [...new Set(ids)])
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`),
locale: z.enum(Object.values(Locales)),
restricted_guilds: z
.array(Int64Type)
.transform((ids) => [...new Set(ids)])
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`),
default_guilds_restricted: z.boolean(),
inline_attachment_media: z.boolean(),
inline_embed_media: z.boolean(),
gif_auto_play: z.boolean(),
render_embeds: z.boolean(),
render_reactions: z.boolean(),
animate_emoji: z.boolean(),
animate_stickers: z.number().int().min(0).max(2),
render_spoilers: z.number().int().min(0).max(2),
message_display_compact: z.boolean(),
friend_source_flags: Int32Type,
incoming_call_flags: Int32Type,
group_dm_add_permission_flags: Int32Type,
guild_folders: z
.array(
z.object({
id: Int32Type,
name: createStringType(1, 32),
color: ColorType.nullish().default(0x000000),
guild_ids: z
.array(Int64Type)
.transform((ids) => [...new Set(ids)])
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`),
}),
)
.max(100)
.default([]),
custom_status: CustomStatusPayload.nullish(),
afk_timeout: z.number().int().min(60).max(600),
time_format: z.number().int().min(0).max(2),
developer_mode: z.boolean(),
})
.partial();
export type UserSettingsUpdateRequest = z.infer<typeof UserSettingsUpdateRequest>;
export const RelationshipResponse = z.object({
id: z.string(),
type: z.number().int(),
user: UserPartialResponse,
since: z.iso.datetime().optional(),
nickname: z.string().nullish(),
});
export type RelationshipResponse = z.infer<typeof RelationshipResponse>;
export const RelationshipNicknameUpdateRequest = z.object({
nickname: createStringType(1, 32)
.nullish()
.transform((value) => (value == null ? null : value.trim() || null))
.optional(),
});
export type RelationshipNicknameUpdateRequest = z.infer<typeof RelationshipNicknameUpdateRequest>;
export const CreatePrivateChannelRequest = z
.object({
recipient_id: Int64Type.optional(),
recipients: z.array(Int64Type).max(9).optional(),
})
.refine((data) => (data.recipient_id && !data.recipients) || (!data.recipient_id && data.recipients), {
message: 'Either recipient_id or recipients must be provided, but not both',
});
export type CreatePrivateChannelRequest = z.infer<typeof CreatePrivateChannelRequest>;
export const FriendRequestByTagRequest = z.object({
username: UsernameType,
discriminator: createStringType(1, 4)
.refine((value) => /^\d{1,4}$/.test(value), 'Discriminator must be 1-4 digits')
.optional()
.default('0'),
});
export type FriendRequestByTagRequest = z.infer<typeof FriendRequestByTagRequest>;
export const BetaCodeResponse = z.object({
code: z.string(),
created_at: z.iso.datetime(),
redeemed_at: z.iso.datetime().nullish(),
redeemer: UserPartialResponse.nullish(),
});
export type BetaCodeResponse = z.infer<typeof BetaCodeResponse>;
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: z.coerce.date().nullish(),
selected_time_window: z.number().int(),
})
.nullish();
const MuteConfigResponseType = z
.object({
end_time: z.iso.datetime().nullish(),
selected_time_window: z.number().int(),
})
.nullish();
const ChannelOverrideType = z.object({
collapsed: z.boolean(),
message_notifications: MessageNotificationsType,
muted: z.boolean(),
mute_config: MuteConfigType,
});
const ChannelOverrideResponseType = z.object({
collapsed: z.boolean(),
message_notifications: z.number().int(),
muted: z.boolean(),
mute_config: MuteConfigResponseType,
});
export const UserGuildSettingsUpdateRequest = z
.object({
message_notifications: MessageNotificationsType,
muted: z.boolean(),
mute_config: MuteConfigType,
mobile_push: z.boolean(),
suppress_everyone: z.boolean(),
suppress_roles: z.boolean(),
hide_muted_channels: z.boolean(),
channel_overrides: z
.record(
Int64Type.transform((val) => val.toString()),
ChannelOverrideType,
)
.nullish(),
})
.partial();
export type UserGuildSettingsUpdateRequest = z.infer<typeof UserGuildSettingsUpdateRequest>;
export const UserGuildSettingsResponse = z.object({
guild_id: z.string().nullish(),
message_notifications: z.number().int(),
muted: z.boolean(),
mute_config: MuteConfigResponseType,
mobile_push: z.boolean(),
suppress_everyone: z.boolean(),
suppress_roles: z.boolean(),
hide_muted_channels: z.boolean(),
channel_overrides: z.record(z.string(), ChannelOverrideResponseType).nullish(),
version: z.number().int(),
});
export type UserGuildSettingsResponse = z.infer<typeof UserGuildSettingsResponse>;

View File

@@ -0,0 +1,711 @@
/*
* 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 {Context} from 'hono';
import type {HonoApp, HonoEnv} from '~/App';
import {requireSudoMode, type SudoVerificationResult} from '~/auth/services/SudoVerificationService';
import {createChannelID, createGuildID, createUserID} from '~/BrandedTypes';
import {UserFlags} from '~/Constants';
import {mapMessageToResponse} from '~/channel/ChannelModel';
import {
AccountSuspiciousActivityError,
InputValidationError,
MissingAccessError,
UnauthorizedError,
UnknownUserError,
} from '~/Errors';
import {Logger} from '~/Logger';
import type {User} from '~/Models';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {SudoModeMiddleware} from '~/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {
createStringType,
DiscriminatorType,
Int64Type,
QueryBooleanType,
SudoVerificationSchema,
URLType,
UsernameType,
z,
} from '~/Schema';
import {getCachedUserPartialResponse, mapUserToPartialResponseWithCache} from '~/user/UserCacheHelpers';
import {
mapGuildMemberToProfileResponse,
mapUserGuildSettingsToResponse,
mapUserSettingsToResponse,
mapUserToOAuthResponse,
mapUserToPrivateResponse,
mapUserToProfileResponse,
UserGuildSettingsUpdateRequest,
UserSettingsUpdateRequest,
UserUpdateRequest,
} from '~/user/UserModel';
import {Validator} from '~/Validator';
const EmailTokenType = createStringType(1, 256);
const UserUpdateWithVerificationRequest = UserUpdateRequest.merge(
z.object({
email_token: EmailTokenType.optional(),
}),
)
.merge(SudoVerificationSchema)
.superRefine((data, ctx) => {
if (data.email !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Email must be changed via email_token',
path: ['email'],
});
}
});
type UserUpdateWithVerificationRequestData = z.infer<typeof UserUpdateWithVerificationRequest>;
type UserUpdatePayload = Omit<
UserUpdateWithVerificationRequestData,
'mfa_method' | 'mfa_code' | 'webauthn_response' | 'webauthn_challenge' | 'email_token'
>;
const requiresSensitiveUserVerification = (
user: User,
data: UserUpdateRequest,
emailTokenProvided: boolean,
): boolean => {
const isUnclaimed = !user.passwordHash;
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;
};
const EmailChangeTicketSchema = z.object({
ticket: createStringType(),
});
const EmailChangeCodeSchema = EmailChangeTicketSchema.extend({
code: createStringType(),
});
const EmailChangeRequestNewSchema = EmailChangeTicketSchema.extend({
new_email: createStringType(),
original_proof: createStringType(),
});
const EmailChangeVerifyNewSchema = EmailChangeCodeSchema.extend({
original_proof: createStringType(),
});
export const UserAccountController = (app: HonoApp) => {
const enforceUserAccess = (user: User): void => {
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
throw new AccountSuspiciousActivityError(user.suspiciousActivityFlags);
}
if ((user.flags & UserFlags.PENDING_MANUAL_VERIFICATION) !== 0n) {
throw new MissingAccessError();
}
};
const handlePreloadMessages = async (ctx: Context<HonoEnv>, channels: ReadonlyArray<bigint>) => {
const channelIds = channels.map(createChannelID);
const messages = await ctx.get('userService').preloadDMMessages({
userId: ctx.get('user').id,
channelIds,
});
const mappingPromises = Object.entries(messages).map(async ([channelId, message]) => {
const mappedMessage = message
? await mapMessageToResponse({
message,
userCacheService: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'),
mediaService: ctx.get('mediaService'),
currentUserId: ctx.get('user').id,
})
: null;
return [channelId, mappedMessage] as const;
});
const mappedEntries = await Promise.all(mappingPromises);
const mappedMessages = Object.fromEntries(mappedEntries);
return ctx.json(mappedMessages);
};
app.get('/users/@me', RateLimitMiddleware(RateLimitConfigs.USER_SETTINGS_GET), async (ctx) => {
const tokenType = ctx.get('authTokenType');
if (tokenType === 'bearer') {
const scopes = ctx.get('oauthBearerScopes');
const bearerUser = ctx.get('user');
if (!scopes || !bearerUser) {
throw new UnauthorizedError();
}
enforceUserAccess(bearerUser);
const includeEmail = scopes.has('email');
return ctx.json(mapUserToOAuthResponse(bearerUser, {includeEmail}));
}
const maybeUser = ctx.get('user');
if (maybeUser) {
enforceUserAccess(maybeUser);
return ctx.json(mapUserToPrivateResponse(maybeUser));
}
throw new UnauthorizedError();
});
app.patch(
'/users/@me',
RateLimitMiddleware(RateLimitConfigs.USER_UPDATE_SELF),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', UserUpdateWithVerificationRequest),
async (ctx) => {
const user = ctx.get('user');
const oldEmail = user.email;
const rawBody: UserUpdateWithVerificationRequestData = ctx.req.valid('json');
const {
mfa_method: _mfaMethod,
mfa_code: _mfaCode,
webauthn_response: _webauthnResponse,
webauthn_challenge: _webauthnChallenge,
email_token: emailToken,
...userUpdateDataRest
} = rawBody;
let userUpdateData: UserUpdatePayload = userUpdateDataRest;
if (userUpdateData.email !== undefined) {
throw InputValidationError.create('email', 'Email must be changed via email_token');
}
const emailTokenProvided = emailToken !== undefined;
const isUnclaimed = !user.passwordHash;
if (isUnclaimed) {
const allowed = new Set(['new_password']);
const disallowedField = Object.keys(userUpdateData).find((key) => !allowed.has(key));
if (disallowedField) {
throw InputValidationError.create(
disallowedField,
'Unclaimed accounts can only set a new email via email_token and a new password',
);
}
}
let emailFromToken: string | null = null;
let emailVerifiedViaToken = false;
const needsVerification = requiresSensitiveUserVerification(user, userUpdateData, emailTokenProvided);
let sudoResult: SudoVerificationResult | null = null;
if (needsVerification) {
sudoResult = await requireSudoMode(ctx, user, rawBody, ctx.get('authService'), ctx.get('authMfaService'));
}
if (emailTokenProvided && emailToken) {
emailFromToken = await ctx.get('emailChangeService').consumeToken(user.id, emailToken);
userUpdateData = {...userUpdateData, email: emailFromToken};
emailVerifiedViaToken = true;
}
const updatedUser = await ctx.get('userService').update({
user,
oldAuthSession: ctx.get('authSession'),
data: userUpdateData,
request: ctx.req.raw,
sudoContext: sudoResult ?? undefined,
emailVerifiedViaToken,
});
if (
emailFromToken &&
oldEmail &&
updatedUser.email &&
oldEmail.toLowerCase() !== updatedUser.email.toLowerCase()
) {
try {
await ctx.get('authService').issueEmailRevertToken(updatedUser, oldEmail, updatedUser.email);
} catch (error) {
Logger.warn({error, userId: updatedUser.id}, 'Failed to issue email revert token');
}
}
return ctx.json(mapUserToPrivateResponse(updatedUser));
},
);
app.post(
'/users/@me/email-change/start',
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_START),
LoginRequired,
DefaultUserOnly,
Validator('json', z.object({}).optional()),
async (ctx) => {
const user = ctx.get('user');
const result = await ctx.get('emailChangeService').start(user);
return ctx.json(result);
},
);
app.post(
'/users/@me/email-change/resend-original',
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_RESEND_ORIGINAL),
LoginRequired,
DefaultUserOnly,
Validator('json', EmailChangeTicketSchema),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await ctx.get('emailChangeService').resendOriginal(user, body.ticket);
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/email-change/verify-original',
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_VERIFY_ORIGINAL),
LoginRequired,
DefaultUserOnly,
Validator('json', EmailChangeCodeSchema),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
const result = await ctx.get('emailChangeService').verifyOriginal(user, body.ticket, body.code);
return ctx.json(result);
},
);
app.post(
'/users/@me/email-change/request-new',
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_REQUEST_NEW),
LoginRequired,
DefaultUserOnly,
Validator('json', EmailChangeRequestNewSchema),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
const result = await ctx
.get('emailChangeService')
.requestNewEmail(user, body.ticket, body.new_email, body.original_proof);
return ctx.json(result);
},
);
app.post(
'/users/@me/email-change/resend-new',
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_RESEND_NEW),
LoginRequired,
DefaultUserOnly,
Validator('json', EmailChangeTicketSchema),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await ctx.get('emailChangeService').resendNew(user, body.ticket);
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/email-change/verify-new',
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_VERIFY_NEW),
LoginRequired,
DefaultUserOnly,
Validator('json', EmailChangeVerifyNewSchema),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
const emailToken = await ctx
.get('emailChangeService')
.verifyNew(user, body.ticket, body.code, body.original_proof);
return ctx.json({email_token: emailToken});
},
);
app.get(
'/users/check-tag',
RateLimitMiddleware(RateLimitConfigs.USER_CHECK_TAG),
LoginRequired,
Validator('query', z.object({username: UsernameType, discriminator: DiscriminatorType})),
async (ctx) => {
const {username, discriminator} = ctx.req.valid('query');
const currentUser = ctx.get('user');
if (
username.toLowerCase() === currentUser.username.toLowerCase() &&
discriminator === currentUser.discriminator
) {
return ctx.json({taken: false});
}
const taken = await ctx.get('userService').checkUsernameDiscriminatorAvailability({username, discriminator});
return ctx.json({taken});
},
);
app.get(
'/users/:user_id',
RateLimitMiddleware(RateLimitConfigs.USER_GET),
LoginRequired,
Validator('param', z.object({user_id: Int64Type})),
async (ctx) => {
const userResponse = await getCachedUserPartialResponse({
userId: createUserID(ctx.req.valid('param').user_id),
userCacheService: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'),
});
return ctx.json(userResponse);
},
);
app.get(
'/users/:target_id/profile',
RateLimitMiddleware(RateLimitConfigs.USER_GET_PROFILE),
LoginRequired,
Validator('param', z.object({target_id: Int64Type})),
Validator(
'query',
z.object({
guild_id: Int64Type.optional(),
with_mutual_friends: QueryBooleanType,
with_mutual_guilds: QueryBooleanType,
}),
),
async (ctx) => {
const {target_id} = ctx.req.valid('param');
const {guild_id, with_mutual_friends, with_mutual_guilds} = ctx.req.valid('query');
const currentUserId = ctx.get('user').id;
const targetUserId = createUserID(target_id);
const guildId = guild_id ? createGuildID(guild_id) : undefined;
const profile = await ctx.get('userService').getUserProfile({
userId: currentUserId,
targetId: targetUserId,
guildId,
withMutualFriends: with_mutual_friends,
withMutualGuilds: with_mutual_guilds,
requestCache: ctx.get('requestCache'),
});
const userProfile = mapUserToProfileResponse(profile.user);
const guildMemberProfile = mapGuildMemberToProfileResponse(profile.guildMemberDomain ?? null);
const mutualFriends = profile.mutualFriends
? await Promise.all(
profile.mutualFriends.map((user) =>
mapUserToPartialResponseWithCache({
user,
userCacheService: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'),
}),
),
)
: undefined;
return ctx.json({
user: await mapUserToPartialResponseWithCache({
user: profile.user,
userCacheService: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'),
}),
user_profile: userProfile,
guild_member: profile.guildMember ?? undefined,
guild_member_profile: guildMemberProfile ?? undefined,
premium_type: profile.premiumType,
premium_since: profile.premiumSince?.toISOString(),
premium_lifetime_sequence: profile.premiumLifetimeSequence,
mutual_friends: mutualFriends,
mutual_guilds: profile.mutualGuilds,
});
},
);
app.get(
'/users/@me/settings',
RateLimitMiddleware(RateLimitConfigs.USER_SETTINGS_GET),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const settings = await ctx.get('userService').findSettings(ctx.get('user').id);
return ctx.json(mapUserSettingsToResponse({settings}));
},
);
app.patch(
'/users/@me/settings',
RateLimitMiddleware(RateLimitConfigs.USER_SETTINGS_UPDATE),
LoginRequired,
DefaultUserOnly,
Validator('json', UserSettingsUpdateRequest),
async (ctx) => {
const updatedSettings = await ctx.get('userService').updateSettings({
userId: ctx.get('user').id,
data: ctx.req.valid('json'),
});
return ctx.json(
mapUserSettingsToResponse({
settings: updatedSettings,
}),
);
},
);
app.get(
'/users/@me/notes',
RateLimitMiddleware(RateLimitConfigs.USER_NOTES_READ),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const notes = await ctx.get('userService').getUserNotes(ctx.get('user').id);
return ctx.json(notes);
},
);
app.get(
'/users/@me/notes/:target_id',
RateLimitMiddleware(RateLimitConfigs.USER_NOTES_READ),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({target_id: Int64Type})),
async (ctx) => {
const note = await ctx.get('userService').getUserNote({
userId: ctx.get('user').id,
targetId: createUserID(ctx.req.valid('param').target_id),
});
if (!note) {
throw new UnknownUserError();
}
return ctx.json(note);
},
);
app.put(
'/users/@me/notes/:target_id',
RateLimitMiddleware(RateLimitConfigs.USER_NOTES_WRITE),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({target_id: Int64Type})),
Validator('json', z.object({note: createStringType(1, 256).nullish()})),
async (ctx) => {
const {target_id} = ctx.req.valid('param');
const {note} = ctx.req.valid('json');
await ctx.get('userService').setUserNote({
userId: ctx.get('user').id,
targetId: createUserID(target_id),
note: note ?? null,
});
return ctx.body(null, 204);
},
);
app.patch(
'/users/@me/guilds/@me/settings',
RateLimitMiddleware(RateLimitConfigs.USER_GUILD_SETTINGS_UPDATE),
LoginRequired,
DefaultUserOnly,
Validator('json', UserGuildSettingsUpdateRequest),
async (ctx) => {
const settings = await ctx.get('userService').updateGuildSettings({
userId: ctx.get('user').id,
guildId: null,
data: ctx.req.valid('json'),
});
return ctx.json(mapUserGuildSettingsToResponse(settings));
},
);
app.patch(
'/users/@me/guilds/:guild_id/settings',
RateLimitMiddleware(RateLimitConfigs.USER_GUILD_SETTINGS_UPDATE),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({guild_id: Int64Type})),
Validator('json', UserGuildSettingsUpdateRequest),
async (ctx) => {
const {guild_id} = ctx.req.valid('param');
const settings = await ctx.get('userService').updateGuildSettings({
userId: ctx.get('user').id,
guildId: createGuildID(guild_id),
data: ctx.req.valid('json'),
});
return ctx.json(mapUserGuildSettingsToResponse(settings));
},
);
app.post(
'/users/@me/disable',
RateLimitMiddleware(RateLimitConfigs.USER_ACCOUNT_DISABLE),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
async (ctx) => {
const userService = ctx.get('userService');
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 userService.selfDisable(user.id);
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/delete',
RateLimitMiddleware(RateLimitConfigs.USER_ACCOUNT_DELETE),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
async (ctx) => {
const userService = ctx.get('userService');
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
await userService.selfDelete(user.id);
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/push/subscribe',
RateLimitMiddleware(RateLimitConfigs.USER_PUSH_SUBSCRIBE),
LoginRequired,
DefaultUserOnly,
Validator(
'json',
z.object({
endpoint: URLType,
keys: z.object({
p256dh: createStringType(1, 1024),
auth: createStringType(1, 1024),
}),
user_agent: createStringType(1, 1024).optional(),
}),
),
async (ctx) => {
const {endpoint, keys, user_agent} = ctx.req.valid('json');
const subscription = await ctx.get('userService').registerPushSubscription({
userId: ctx.get('user').id,
endpoint,
keys,
userAgent: user_agent,
});
return ctx.json({subscription_id: subscription.subscriptionId});
},
);
app.get(
'/users/@me/push/subscriptions',
RateLimitMiddleware(RateLimitConfigs.USER_PUSH_LIST),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const subscriptions = await ctx.get('userService').listPushSubscriptions(ctx.get('user').id);
return ctx.json({
subscriptions: subscriptions.map((sub) => ({
subscription_id: sub.subscriptionId,
user_agent: sub.userAgent,
})),
});
},
);
app.delete(
'/users/@me/push/subscriptions/:subscription_id',
RateLimitMiddleware(RateLimitConfigs.USER_PUSH_UNSUBSCRIBE),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({subscription_id: createStringType(1, 256)})),
async (ctx) => {
const {subscription_id} = ctx.req.valid('param');
await ctx.get('userService').deletePushSubscription(ctx.get('user').id, subscription_id);
return ctx.json({success: true});
},
);
app.post(
'/users/@me/preload-messages',
RateLimitMiddleware(RateLimitConfigs.USER_PRELOAD_MESSAGES),
LoginRequired,
Validator('json', z.object({channels: z.array(Int64Type).max(100)})),
async (ctx) => handlePreloadMessages(ctx, ctx.req.valid('json').channels),
);
app.post(
'/users/@me/channels/messages/preload',
RateLimitMiddleware(RateLimitConfigs.USER_PRELOAD_MESSAGES),
LoginRequired,
Validator('json', z.object({channels: z.array(Int64Type).max(100)})),
async (ctx) => handlePreloadMessages(ctx, ctx.req.valid('json').channels),
);
app.post(
'/users/@me/messages/delete',
RateLimitMiddleware(RateLimitConfigs.USER_BULK_MESSAGE_DELETE),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
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('userService').requestBulkMessageDeletion({userId: user.id});
return ctx.body(null, 204);
},
);
app.delete(
'/users/@me/messages/delete',
RateLimitMiddleware(RateLimitConfigs.USER_BULK_MESSAGE_DELETE),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const user = ctx.get('user');
await ctx.get('userService').cancelBulkMessageDeletion(user.id);
return ctx.json({success: true});
},
);
app.post(
'/users/@me/messages/delete/test',
RateLimitMiddleware(RateLimitConfigs.USER_BULK_MESSAGE_DELETE),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const user = ctx.get('user');
if (!(user.flags & UserFlags.STAFF)) {
throw new MissingAccessError();
}
await ctx.get('userService').requestBulkMessageDeletion({
userId: user.id,
delayMs: 60 * 1000,
});
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,331 @@
/*
* 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 {RegistrationResponseJSON} from '@simplewebauthn/server';
import type {HonoApp} from '~/App';
import {requireSudoMode} from '~/auth/services/SudoVerificationService';
import {DefaultUserOnly, LoginRequired, LoginRequiredAllowSuspicious} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {SudoModeMiddleware} from '~/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {createStringType, PasswordType, PhoneNumberType, SudoVerificationSchema, z} from '~/Schema';
import {Validator} from '~/Validator';
const DisableTotpSchema = z
.object({code: createStringType(), password: PasswordType.optional()})
.merge(SudoVerificationSchema);
const MfaBackupCodesSchema = z
.object({regenerate: z.boolean(), password: PasswordType.optional()})
.merge(SudoVerificationSchema);
export const UserAuthController = (app: HonoApp) => {
app.post(
'/users/@me/mfa/totp/enable',
RateLimitMiddleware(RateLimitConfigs.USER_MFA_TOTP_ENABLE),
LoginRequired,
DefaultUserOnly,
Validator('json', z.object({secret: createStringType(), code: createStringType()})),
async (ctx) => {
const {secret, code} = ctx.req.valid('json');
const backupCodes = await ctx.get('userService').enableMfaTotp({
user: ctx.get('user'),
secret,
code,
});
return ctx.json({
backup_codes: backupCodes.map((bc) => ({
code: bc.code,
consumed: bc.consumed,
})),
});
},
);
app.post(
'/users/@me/mfa/totp/disable',
RateLimitMiddleware(RateLimitConfigs.USER_MFA_TOTP_DISABLE),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', DisableTotpSchema),
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('userService').disableMfaTotp({
user,
code: body.code,
sudoContext: sudoResult,
password: body.password,
});
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/mfa/backup-codes',
RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', MfaBackupCodesSchema),
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'));
const backupCodes = await ctx.get('userService').getMfaBackupCodes({
user,
regenerate: body.regenerate,
sudoContext: sudoResult,
password: body.password,
});
return ctx.json({
backup_codes: backupCodes.map((bc) => ({
code: bc.code,
consumed: bc.consumed,
})),
});
},
);
app.post(
'/users/@me/phone/send-verification',
RateLimitMiddleware(RateLimitConfigs.PHONE_SEND_VERIFICATION),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator('json', z.object({phone: PhoneNumberType})),
async (ctx) => {
const {phone} = ctx.req.valid('json');
await ctx.get('authService').sendPhoneVerificationCode(phone, ctx.get('user').id);
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/phone/verify',
RateLimitMiddleware(RateLimitConfigs.PHONE_VERIFY_CODE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator('json', z.object({phone: PhoneNumberType, code: createStringType()})),
async (ctx) => {
const {phone, code} = ctx.req.valid('json');
const phoneToken = await ctx.get('authService').verifyPhoneCode(phone, code, ctx.get('user').id);
return ctx.json({phone_token: phoneToken});
},
);
app.post(
'/users/@me/phone',
RateLimitMiddleware(RateLimitConfigs.PHONE_ADD),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', z.object({phone_token: createStringType()}).merge(SudoVerificationSchema)),
async (ctx) => {
const user = ctx.get('user');
const {phone_token, ...sudoBody} = ctx.req.valid('json');
await requireSudoMode(ctx, user, sudoBody, ctx.get('authService'), ctx.get('authMfaService'));
await ctx.get('authService').addPhoneToAccount(user.id, phone_token);
return ctx.body(null, 204);
},
);
app.delete(
'/users/@me/phone',
RateLimitMiddleware(RateLimitConfigs.PHONE_REMOVE),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
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('authService').removePhoneFromAccount(user.id);
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/mfa/sms/enable',
RateLimitMiddleware(RateLimitConfigs.MFA_SMS_ENABLE),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
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('authService').enableSmsMfa(user.id);
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/mfa/sms/disable',
RateLimitMiddleware(RateLimitConfigs.MFA_SMS_DISABLE),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
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('authService').disableSmsMfa(user.id);
return ctx.body(null, 204);
},
);
app.get(
'/users/@me/mfa/webauthn/credentials',
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_LIST),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const credentials = await ctx.get('userRepository').listWebAuthnCredentials(ctx.get('user').id);
return ctx.json(
credentials.map((cred) => ({
id: cred.credentialId,
name: cred.name,
created_at: cred.createdAt.toISOString(),
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
})),
);
},
);
app.post(
'/users/@me/mfa/webauthn/credentials/registration-options',
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_REGISTRATION_OPTIONS),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
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,
});
const options = await ctx.get('authService').generateWebAuthnRegistrationOptions(user.id);
return ctx.json(options);
},
);
app.post(
'/users/@me/mfa/webauthn/credentials',
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_REGISTER),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator(
'json',
z
.object({
response: z.custom<RegistrationResponseJSON>(),
challenge: createStringType(),
name: createStringType(1, 100),
})
.merge(SudoVerificationSchema),
),
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('authService').verifyWebAuthnRegistration(user.id, 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', z.object({credential_id: createStringType()})),
Validator('json', z.object({name: createStringType(1, 100)}).merge(SudoVerificationSchema)),
SudoModeMiddleware,
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('authService').renameWebAuthnCredential(user.id, credential_id, name);
return ctx.body(null, 204);
},
);
app.delete(
'/users/@me/mfa/webauthn/credentials/:credential_id',
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_DELETE),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({credential_id: createStringType()})),
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
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('authService').deleteWebAuthnCredential(user.id, credential_id);
return ctx.body(null, 204);
},
);
app.get(
'/users/@me/sudo/mfa-methods',
RateLimitMiddleware(RateLimitConfigs.SUDO_MFA_METHODS),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const methods = await ctx.get('authMfaService').getAvailableMfaMethods(ctx.get('user').id);
return ctx.json(methods);
},
);
app.post(
'/users/@me/sudo/mfa/sms/send',
RateLimitMiddleware(RateLimitConfigs.SUDO_SMS_SEND),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
await ctx.get('authService').sendSmsMfaCode(ctx.get('user').id);
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/sudo/webauthn/authentication-options',
RateLimitMiddleware(RateLimitConfigs.SUDO_WEBAUTHN_OPTIONS),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const options = await ctx.get('authMfaService').generateWebAuthnOptionsForSudo(ctx.get('user').id);
return ctx.json(options);
},
);
};

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 type {HonoApp} from '~/App';
import {createChannelID} from '~/BrandedTypes';
import {mapChannelToResponse} from '~/channel/ChannelModel';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, z} from '~/Schema';
import {CreatePrivateChannelRequest} from '~/user/UserModel';
import {Validator} from '~/Validator';
export const UserChannelController = (app: HonoApp) => {
app.get(
'/users/@me/channels',
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const userId = ctx.get('user').id;
const channels = await ctx.get('userService').getPrivateChannels(userId);
const responses = await Promise.all(
channels.map((channel) =>
mapChannelToResponse({
channel,
currentUserId: userId,
userCacheService: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'),
}),
),
);
return ctx.json(responses);
},
);
app.post(
'/users/@me/channels',
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
LoginRequired,
DefaultUserOnly,
Validator('json', CreatePrivateChannelRequest),
async (ctx) => {
const userId = ctx.get('user').id;
const channel = await ctx.get('userService').createOrOpenDMChannel({
userId,
data: ctx.req.valid('json'),
userCacheService: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'),
});
return ctx.json(
await mapChannelToResponse({
channel,
currentUserId: userId,
userCacheService: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'),
}),
);
},
);
app.put(
'/users/@me/channels/:channel_id/pin',
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({channel_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
await ctx.get('userService').pinDmChannel({
userId,
channelId,
});
return ctx.body(null, 204);
},
);
app.delete(
'/users/@me/channels/:channel_id/pin',
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({channel_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
await ctx.get('userService').unpinDmChannel({
userId,
channelId,
});
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,266 @@
/*
* 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 '~/App';
import {createChannelID, createMessageID, type UserID} from '~/BrandedTypes';
import {mapMessageToResponse} from '~/channel/ChannelModel';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {createQueryIntegerType, createStringType, Int64Type, QueryBooleanType, z} from '~/Schema';
import {getCachedUserPartialResponse} from '~/user/UserCacheHelpers';
import {mapBetaCodeToResponse} from '~/user/UserModel';
import type {SavedMessageEntryResponse} from '~/user/UserTypes';
import {Validator} from '~/Validator';
const createUserPartialResolver =
(userCacheService: UserCacheService, requestCache: RequestCache) => (userId: UserID) =>
getCachedUserPartialResponse({userId, userCacheService, requestCache});
export const UserContentController = (app: HonoApp) => {
app.get(
'/users/@me/beta-codes',
RateLimitMiddleware(RateLimitConfigs.USER_BETA_CODES_READ),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const userId = ctx.get('user').id;
const userService = ctx.get('userService');
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
const [betaCodes, allowanceInfo] = await Promise.all([
userService.getBetaCodes(userId),
userService.getBetaCodeAllowanceInfo(userId),
]);
const responses = await Promise.all(
betaCodes.map((betaCode) => mapBetaCodeToResponse({betaCode, userPartialResolver})),
);
return ctx.json({
beta_codes: responses,
allowance: allowanceInfo.allowance,
next_reset_at: allowanceInfo.nextResetAt?.toISOString() ?? null,
});
},
);
app.post(
'/users/@me/beta-codes',
RateLimitMiddleware(RateLimitConfigs.USER_BETA_CODES_CREATE),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
const betaCode = await ctx.get('userService').createBetaCode(ctx.get('user').id);
return ctx.json(await mapBetaCodeToResponse({betaCode, userPartialResolver}));
},
);
app.delete(
'/users/@me/beta-codes/:code',
RateLimitMiddleware(RateLimitConfigs.USER_BETA_CODES_DELETE),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({code: createStringType()})),
async (ctx) => {
await ctx.get('userService').deleteBetaCode({
userId: ctx.get('user').id,
code: ctx.req.valid('param').code,
});
return ctx.body(null, 204);
},
);
app.get(
'/users/@me/mentions',
RateLimitMiddleware(RateLimitConfigs.USER_MENTIONS_READ),
LoginRequired,
DefaultUserOnly,
Validator(
'query',
z.object({
limit: createQueryIntegerType({minValue: 1, maxValue: 100, defaultValue: 25}),
roles: QueryBooleanType.optional().default(true),
everyone: QueryBooleanType.optional().default(true),
guilds: QueryBooleanType.optional().default(true),
before: Int64Type.optional(),
}),
),
async (ctx) => {
const {limit, roles, everyone, guilds, before} = ctx.req.valid('query');
const userId = ctx.get('user').id;
const messages = await ctx.get('userService').getRecentMentions({
userId,
limit,
everyone,
roles,
guilds,
before: before ? createMessageID(before) : undefined,
});
const responses = await Promise.all(
messages.map((message) =>
mapMessageToResponse({
message,
currentUserId: userId,
userCacheService: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'),
mediaService: ctx.get('mediaService'),
}),
),
);
return ctx.json(responses);
},
);
app.delete(
'/users/@me/mentions/:message_id',
RateLimitMiddleware(RateLimitConfigs.USER_MENTIONS_DELETE),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({message_id: Int64Type})),
async (ctx) => {
await ctx.get('userService').deleteRecentMention({
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', z.object({limit: createQueryIntegerType({minValue: 1, maxValue: 100, defaultValue: 25})})),
async (ctx) => {
const userId = ctx.get('user').id;
const entries = await ctx.get('userService').getSavedMessages({
userId,
limit: ctx.req.valid('query').limit,
});
const responses = await Promise.all(
entries.map(async (entry) => {
const response: SavedMessageEntryResponse = {
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: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'),
mediaService: ctx.get('mediaService'),
})
: null,
};
return response;
}),
);
return ctx.json(responses, 200);
},
);
app.post(
'/users/@me/saved-messages',
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
LoginRequired,
DefaultUserOnly,
Validator('json', z.object({channel_id: Int64Type, message_id: Int64Type})),
async (ctx) => {
const {channel_id, message_id} = ctx.req.valid('json');
await ctx.get('userService').saveMessage({
userId: ctx.get('user').id,
channelId: createChannelID(channel_id),
messageId: createMessageID(message_id),
userCacheService: ctx.get('userCacheService'),
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', z.object({message_id: Int64Type})),
async (ctx) => {
await ctx.get('userService').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,
async (ctx) => {
const result = await ctx.get('userService').requestDataHarvest(ctx.get('user').id);
return ctx.json(result, 200);
},
);
app.get(
'/users/@me/harvest/latest',
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_LATEST),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const harvest = await ctx.get('userService').getLatestHarvest(ctx.get('user').id);
return ctx.json(harvest, 200);
},
);
app.get(
'/users/@me/harvest/:harvestId',
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_STATUS),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const harvestId = BigInt(ctx.req.param('harvestId'));
const harvest = await ctx.get('userService').getHarvestStatus(ctx.get('user').id, harvestId);
return ctx.json(harvest, 200);
},
);
app.get(
'/users/@me/harvest/:harvestId/download',
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_DOWNLOAD),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const harvestId = BigInt(ctx.req.param('harvestId'));
const result = await ctx
.get('userService')
.getHarvestDownloadUrl(ctx.get('user').id, harvestId, 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 '~/App';
import {UserAccountController} from './UserAccountController';
import {UserAuthController} from './UserAuthController';
import {UserChannelController} from './UserChannelController';
import {UserContentController} from './UserContentController';
import {UserRelationshipController} from './UserRelationshipController';
import {UserScheduledMessageController} from './UserScheduledMessageController';
export const UserController = (app: HonoApp) => {
UserAccountController(app);
UserAuthController(app);
UserRelationshipController(app);
UserChannelController(app);
UserContentController(app);
UserScheduledMessageController(app);
};

View File

@@ -0,0 +1,160 @@
/*
* 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 '~/App';
import {createUserID, type UserID} from '~/BrandedTypes';
import {RelationshipTypes} from '~/Constants';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, z} from '~/Schema';
import {getCachedUserPartialResponse} from '~/user/UserCacheHelpers';
import {
FriendRequestByTagRequest,
mapRelationshipToResponse,
RelationshipNicknameUpdateRequest,
} from '~/user/UserModel';
import {Validator} from '~/Validator';
const createUserPartialResolver =
(userCacheService: UserCacheService, requestCache: RequestCache) => (userId: UserID) =>
getCachedUserPartialResponse({userId, userCacheService, requestCache});
export const UserRelationshipController = (app: HonoApp) => {
app.get(
'/users/@me/relationships',
RateLimitMiddleware(RateLimitConfigs.USER_RELATIONSHIPS_LIST),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
const relationships = await ctx.get('userService').getRelationships(ctx.get('user').id);
const responses = await Promise.all(
relationships.map((relationship) => mapRelationshipToResponse({relationship, userPartialResolver})),
);
return ctx.json(responses);
},
);
app.post(
'/users/@me/relationships',
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_SEND),
LoginRequired,
DefaultUserOnly,
Validator('json', FriendRequestByTagRequest),
async (ctx) => {
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
const relationship = await ctx.get('userService').sendFriendRequestByTag({
userId: ctx.get('user').id,
data: ctx.req.valid('json'),
userCacheService: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'),
});
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
},
);
app.post(
'/users/@me/relationships/:user_id',
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_SEND),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({user_id: Int64Type})),
async (ctx) => {
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
const relationship = await ctx.get('userService').sendFriendRequest({
userId: ctx.get('user').id,
targetId: createUserID(ctx.req.valid('param').user_id),
userCacheService: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'),
});
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
},
);
app.put(
'/users/@me/relationships/:user_id',
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_ACCEPT),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({user_id: Int64Type})),
Validator('json', z.object({type: z.number().optional()}).optional()),
async (ctx) => {
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
const body = ctx.req.valid('json');
const targetId = createUserID(ctx.req.valid('param').user_id);
if (body?.type === RelationshipTypes.BLOCKED) {
const relationship = await ctx.get('userService').blockUser({
userId: ctx.get('user').id,
targetId,
userCacheService: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'),
});
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
} else {
const relationship = await ctx.get('userService').acceptFriendRequest({
userId: ctx.get('user').id,
targetId,
userCacheService: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'),
});
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
}
},
);
app.delete(
'/users/@me/relationships/:user_id',
RateLimitMiddleware(RateLimitConfigs.USER_RELATIONSHIP_DELETE),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({user_id: Int64Type})),
async (ctx) => {
await ctx.get('userService').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', z.object({user_id: Int64Type})),
Validator('json', RelationshipNicknameUpdateRequest),
async (ctx) => {
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
const targetId = createUserID(ctx.req.valid('param').user_id);
const requestBody = ctx.req.valid('json');
const relationship = await ctx.get('userService').updateFriendNickname({
userId: ctx.get('user').id,
targetId,
nickname: requestBody.nickname ?? null,
userCacheService: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'),
});
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
},
);
};

View File

@@ -0,0 +1,120 @@
/*
* 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 {Context} from 'hono';
import type {HonoApp, HonoEnv} from '~/App';
import {createMessageID} from '~/BrandedTypes';
import {parseScheduledMessageInput} from '~/channel/controllers/ScheduledMessageParsing';
import {UnknownMessageError} from '~/Errors';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, z} from '~/Schema';
import {Validator} from '~/Validator';
export const UserScheduledMessageController = (app: HonoApp) => {
app.get(
'/users/@me/scheduled-messages',
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
LoginRequired,
DefaultUserOnly,
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', z.object({scheduled_message_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const scheduledMessageId = createMessageID(BigInt(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', z.object({scheduled_message_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const scheduledMessageId = createMessageID(BigInt(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', z.object({scheduled_message_id: Int64Type})),
async (ctx) => {
const user = ctx.get('user');
const scheduledMessageService = ctx.get('scheduledMessageService');
const scheduledMessageId = createMessageID(BigInt(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,
userId: user.id,
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,121 @@
/*
* 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 {createBetaCode, type UserID} from '~/BrandedTypes';
import {BatchBuilder, Db, fetchMany, fetchOne} from '~/database/Cassandra';
import type {BetaCodeByCodeRow, BetaCodeRow} from '~/database/CassandraTypes';
import {BetaCode} from '~/Models';
import {BetaCodes, BetaCodesByCode} from '~/Tables';
const FETCH_BETA_CODES_BY_CREATOR_QUERY = BetaCodes.selectCql({
where: BetaCodes.where.eq('creator_id'),
});
const FETCH_BETA_CODE_BY_CREATOR_AND_CODE_QUERY = BetaCodes.selectCql({
where: [BetaCodes.where.eq('creator_id'), BetaCodes.where.eq('code')],
limit: 1,
});
const FETCH_BETA_CODE_BY_CODE_QUERY = BetaCodesByCode.selectCql({
where: BetaCodesByCode.where.eq('code'),
});
const FETCH_BETA_CODES_BY_CREATOR_FOR_DELETE_QUERY = BetaCodes.selectCql({
columns: ['code'],
where: BetaCodes.where.eq('creator_id', 'user_id'),
});
export class BetaCodeRepository {
async listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>> {
const betaCodes = await fetchMany<BetaCodeRow>(FETCH_BETA_CODES_BY_CREATOR_QUERY, {
creator_id: creatorId,
});
return betaCodes.map((betaCode) => new BetaCode(betaCode));
}
async getBetaCode(code: string): Promise<BetaCode | null> {
const betaCodeByCode = await fetchOne<BetaCodeByCodeRow>(FETCH_BETA_CODE_BY_CODE_QUERY, {code});
if (!betaCodeByCode) {
return null;
}
const betaCode = await fetchOne<BetaCodeRow>(FETCH_BETA_CODE_BY_CREATOR_AND_CODE_QUERY, {
creator_id: betaCodeByCode.creator_id,
code: betaCodeByCode.code,
});
return betaCode ? new BetaCode(betaCode) : null;
}
async upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode> {
const batch = new BatchBuilder();
batch.addPrepared(BetaCodes.upsertAll(betaCode));
batch.addPrepared(
BetaCodesByCode.upsertAll({
code: betaCode.code,
creator_id: betaCode.creator_id,
}),
);
await batch.execute();
return new BetaCode(betaCode);
}
async updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void> {
const betaCodeByCode = await fetchOne<BetaCodeByCodeRow>(FETCH_BETA_CODE_BY_CODE_QUERY, {code});
if (!betaCodeByCode) {
return;
}
const batch = new BatchBuilder();
batch.addPrepared(
BetaCodes.patchByPk(
{
creator_id: betaCodeByCode.creator_id,
code: betaCodeByCode.code,
},
{
redeemer_id: Db.set(redeemerId),
redeemed_at: Db.set(redeemedAt),
},
),
);
await batch.execute();
}
async deleteBetaCode(code: string, creatorId: UserID): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(BetaCodes.deleteByPk({creator_id: creatorId, code: createBetaCode(code)}));
batch.addPrepared(BetaCodesByCode.deleteByPk({code: createBetaCode(code), creator_id: creatorId}));
await batch.execute();
}
async deleteAllBetaCodes(userId: UserID): Promise<void> {
const codes = await fetchMany<{code: string}>(FETCH_BETA_CODES_BY_CREATOR_FOR_DELETE_QUERY, {
user_id: userId,
});
const batch = new BatchBuilder();
for (const betaCode of codes) {
batch.addPrepared(BetaCodes.deleteByPk({creator_id: userId, code: createBetaCode(betaCode.code)}));
batch.addPrepared(BetaCodesByCode.deleteByPk({code: createBetaCode(betaCode.code), creator_id: userId}));
}
await batch.execute();
}
}

View File

@@ -0,0 +1,160 @@
/*
* 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 '~/BrandedTypes';
import {BatchBuilder, Db, executeConditional, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {GiftCodeRow} from '~/database/CassandraTypes';
import {GiftCode} from '~/Models';
import {GiftCodes, GiftCodesByCreator, GiftCodesByPaymentIntent, GiftCodesByRedeemer} from '~/Tables';
const FETCH_GIFT_CODES_BY_CREATOR_QUERY = GiftCodesByCreator.selectCql({
columns: ['code'],
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 rows = await fetchMany<{code: string}>(FETCH_GIFT_CODES_BY_CREATOR_QUERY, {
created_by_user_id: userId,
});
const giftCodes = await Promise.all(rows.map((row) => this.findGiftCode(row.code)));
return giftCodes.filter((gc) => gc !== null) as Array<GiftCode>;
}
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,52 @@
/*
* 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 '~/BrandedTypes';
import type {UserRow} from '~/database/CassandraTypes';
import type {User} from '~/Models';
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 | null>;
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>;
}

View File

@@ -0,0 +1,93 @@
/*
* 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 '~/BrandedTypes';
import type {
AuthSessionRow,
EmailRevertTokenRow,
EmailVerificationTokenRow,
PasswordResetTokenRow,
PhoneTokenRow,
} from '~/database/CassandraTypes';
import type {
AuthSession,
EmailRevertToken,
EmailVerificationToken,
MfaBackupCode,
PasswordResetToken,
WebAuthnCredential,
} from '~/Models';
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): 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>;
createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void>;
deletePendingVerification(userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,49 @@
/*
* 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 '~/BrandedTypes';
import type {Channel} from '~/Models';
export interface PrivateChannelSummary {
channelId: ChannelID;
isGroupDm: boolean;
channelType: number | null;
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>>;
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,91 @@
/*
* 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 '~/BrandedTypes';
import type {
BetaCodeRow,
GiftCodeRow,
PaymentBySubscriptionRow,
PaymentRow,
PushSubscriptionRow,
RecentMentionRow,
} from '~/database/CassandraTypes';
import type {BetaCode, GiftCode, Payment, PushSubscription, RecentMention, SavedMessage, VisionarySlot} from '~/Models';
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: RecentMentionRow): Promise<RecentMention>;
createRecentMentions(mentions: Array<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>;
listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>>;
getBetaCode(code: string): Promise<BetaCode | null>;
upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode>;
updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void>;
deleteBetaCode(code: string, creatorId: UserID): Promise<void>;
deleteAllBetaCodes(userId: UserID): Promise<void>;
createGiftCode(data: 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: 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,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 {UserID} from '~/BrandedTypes';
import type {RelationshipRow} from '~/database/CassandraTypes';
import type {Relationship, UserNote} from '~/Models';
export interface IUserRelationshipRepository {
listRelationships(sourceUserId: UserID): Promise<Array<Relationship>>;
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>;
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 './IUserAccountRepository';
import type {IUserAuthRepository} from './IUserAuthRepository';
import type {IUserChannelRepository} from './IUserChannelRepository';
import type {IUserContentRepository} from './IUserContentRepository';
import type {IUserRelationshipRepository} from './IUserRelationshipRepository';
import type {IUserSettingsRepository} from './IUserSettingsRepository';
export interface IUserRepositoryAggregate
extends IUserAccountRepository,
IUserAuthRepository,
IUserSettingsRepository,
IUserRelationshipRepository,
IUserChannelRepository,
IUserContentRepository {}

View File

@@ -0,0 +1,34 @@
/*
* 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 '~/BrandedTypes';
import type {UserGuildSettingsRow, UserSettingsRow} from '~/database/CassandraTypes';
import type {UserGuildSettings, UserSettings} from '~/Models';
export interface IUserSettingsRepository {
findSettings(userId: UserID): Promise<UserSettings | null>;
upsertSettings(settings: 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: UserGuildSettingsRow): Promise<UserGuildSettings>;
deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void>;
deleteAllUserGuildSettings(userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,205 @@
/*
* 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 '~/BrandedTypes';
import {BatchBuilder, Db, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import type {PaymentBySubscriptionRow, PaymentRow} from '~/database/CassandraTypes';
import {Payment} from '~/Models';
import {Payments, PaymentsByPaymentIntent, PaymentsBySubscription, PaymentsByUser} from '~/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,
{onFailure: 'throw'},
);
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 '~/BrandedTypes';
import {deleteOneOrMany, fetchMany, prepared, upsertOne} from '~/database/Cassandra';
import type {PushSubscriptionRow} from '~/database/CassandraTypes';
import {PushSubscription} from '~/Models';
import {PushSubscriptions} from '~/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(
prepared(PushSubscriptions.deleteCql({where: PushSubscriptions.where.eq('user_id')}), {user_id: userId}),
);
}
}

View File

@@ -0,0 +1,153 @@
/*
* 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 '~/BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne, prepared} from '~/database/Cassandra';
import type {RecentMentionRow} from '~/database/CassandraTypes';
import {RecentMention} from '~/Models';
import {RecentMentions, RecentMentionsByGuild} from '~/Tables';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
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(SnowflakeUtils.getSnowflake()),
};
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(
prepared(RecentMentions.deleteCql({where: RecentMentions.where.eq('user_id')}), {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,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 {type ChannelID, createMessageID, type MessageID, type UserID} from '~/BrandedTypes';
import {deleteOneOrMany, fetchMany, prepared, upsertOne} from '~/database/Cassandra';
import type {SavedMessageRow} from '~/database/CassandraTypes';
import {SavedMessage} from '~/Models';
import {SavedMessages} from '~/Tables';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
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(SnowflakeUtils.getSnowflake()),
): 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(
prepared(SavedMessages.deleteCql({where: SavedMessages.where.eq('user_id')}), {user_id: userId}),
);
}
}

View File

@@ -0,0 +1,80 @@
/*
* 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 '~/BrandedTypes';
import {Db, deleteOneOrMany, fetchMany, fetchOne} from '~/database/Cassandra';
import type {ScheduledMessageRow} from '~/database/CassandraTypes';
import {ScheduledMessage} from '~/models/ScheduledMessage';
import {ScheduledMessages} from '~/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 ScheduledMessages.upsertAllWithTtl(message.toRow(), ttlSeconds);
}
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 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,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 {GuildID, UserID} from '~/BrandedTypes';
import type {UserRow} from '~/database/CassandraTypes';
import type {User} from '~/Models';
import {UserAccountRepository as UserAccountCrudRepository} from './account/UserAccountRepository';
import {UserDeletionRepository} from './account/UserDeletionRepository';
import {UserGuildRepository} from './account/UserGuildRepository';
import {UserLookupRepository} from './account/UserLookupRepository';
import type {IUserAccountRepository} from './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 | null> {
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 user = await this.findUnique(userId);
return {
last_active_at: user?.lastActiveAt ?? null,
last_active_ip: user?.lastActiveIp ?? 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);
}
}

View File

@@ -0,0 +1,240 @@
/*
* 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 '~/BrandedTypes';
import type {
AuthSessionRow,
EmailRevertTokenRow,
EmailVerificationTokenRow,
PasswordResetTokenRow,
PhoneTokenRow,
} from '~/database/CassandraTypes';
import type {
AuthSession,
EmailRevertToken,
EmailVerificationToken,
MfaBackupCode,
PasswordResetToken,
WebAuthnCredential,
} from '~/Models';
import {AuthSessionRepository} from './auth/AuthSessionRepository';
import {IpAuthorizationRepository} from './auth/IpAuthorizationRepository';
import {MfaBackupCodeRepository} from './auth/MfaBackupCodeRepository';
import {PendingVerificationRepository} from './auth/PendingVerificationRepository';
import {TokenRepository} from './auth/TokenRepository';
import {WebAuthnRepository} from './auth/WebAuthnRepository';
import type {IUserAccountRepository} from './IUserAccountRepository';
import type {IUserAuthRepository} from './IUserAuthRepository';
export class UserAuthRepository implements IUserAuthRepository {
private authSessionRepository: AuthSessionRepository;
private mfaBackupCodeRepository: MfaBackupCodeRepository;
private tokenRepository: TokenRepository;
private ipAuthorizationRepository: IpAuthorizationRepository;
private webAuthnRepository: WebAuthnRepository;
private pendingVerificationRepository: PendingVerificationRepository;
constructor(userAccountRepository: IUserAccountRepository) {
this.authSessionRepository = new AuthSessionRepository();
this.mfaBackupCodeRepository = new MfaBackupCodeRepository();
this.tokenRepository = new TokenRepository();
this.ipAuthorizationRepository = new IpAuthorizationRepository(userAccountRepository);
this.webAuthnRepository = new WebAuthnRepository();
this.pendingVerificationRepository = new PendingVerificationRepository();
}
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): Promise<void> {
return this.ipAuthorizationRepository.createIpAuthorizationToken(userId, token);
}
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);
}
async createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void> {
return this.pendingVerificationRepository.createPendingVerification(userId, createdAt, metadata);
}
async deletePendingVerification(userId: UserID): Promise<void> {
return this.pendingVerificationRepository.deletePendingVerification(userId);
}
}

View File

@@ -0,0 +1,369 @@
/*
* 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 '~/BrandedTypes';
import {ChannelTypes} from '~/Constants';
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchManyInChunks, fetchOne, upsertOne} from '~/database/Cassandra';
import type {ChannelRow, DmStateRow, PrivateChannelRow} from '~/database/CassandraTypes';
import {Channel} from '~/Models';
import {Channels, DmStates, PinnedDms, PrivateChannels, ReadStates} from '~/Tables';
import type {IUserChannelRepository, PrivateChannelSummary} from './IUserChannelRepository';
interface PinnedDmRow {
user_id: UserID;
channel_id: ChannelID;
sort_order: number;
}
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_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_CHANNELS_IN_CQL = Channels.selectCql({
where: [Channels.where.in('channel_id', 'channel_ids'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
});
const sortBySortOrder = (a: PinnedDmRow, b: PinnedDmRow): number => 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);
}
const highestSortOrder = pinnedDms.length > 0 ? Math.max(...pinnedDms.map((dm) => dm.sort_order)) : -1;
await upsertOne(
PinnedDms.upsertAll({
user_id: userId,
channel_id: channelId,
sort_order: highestSortOrder + 1,
}),
);
const allPinnedDms: Array<PinnedDmRow> = [
...pinnedDms,
{
user_id: userId,
channel_id: channelId,
sort_order: highestSortOrder + 1,
},
];
return allPinnedDms.sort(sortBySortOrder).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 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 upsertOne(
PrivateChannels.upsertAll({
user_id: userId,
channel_id: channelId,
is_gdm: resolvedIsGroupDm,
}),
);
}
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 '~/BrandedTypes';
import {fetchMany, upsertOne} from '~/database/Cassandra';
import type {UserContactChangeLogRow} from '~/database/CassandraTypes';
import {UserContactChangeLogs} from '~/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,231 @@
/*
* 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 '~/BrandedTypes';
import type {
BetaCodeRow,
GiftCodeRow,
PaymentBySubscriptionRow,
PaymentRow,
PushSubscriptionRow,
RecentMentionRow,
} from '~/database/CassandraTypes';
import type {BetaCode, GiftCode, Payment, PushSubscription, RecentMention, SavedMessage, VisionarySlot} from '~/Models';
import {BetaCodeRepository} from './BetaCodeRepository';
import {GiftCodeRepository} from './GiftCodeRepository';
import type {IUserContentRepository} from './IUserContentRepository';
import {PaymentRepository} from './PaymentRepository';
import {PushSubscriptionRepository} from './PushSubscriptionRepository';
import {RecentMentionRepository} from './RecentMentionRepository';
import {SavedMessageRepository} from './SavedMessageRepository';
import {VisionarySlotRepository} from './VisionarySlotRepository';
export class UserContentRepository implements IUserContentRepository {
private betaCodeRepository: BetaCodeRepository;
private giftCodeRepository: GiftCodeRepository;
private paymentRepository: PaymentRepository;
private pushSubscriptionRepository: PushSubscriptionRepository;
private recentMentionRepository: RecentMentionRepository;
private savedMessageRepository: SavedMessageRepository;
private visionarySlotRepository: VisionarySlotRepository;
constructor() {
this.betaCodeRepository = new BetaCodeRepository();
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 listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>> {
return this.betaCodeRepository.listBetaCodes(creatorId);
}
async getBetaCode(code: string): Promise<BetaCode | null> {
return this.betaCodeRepository.getBetaCode(code);
}
async upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode> {
return this.betaCodeRepository.upsertBetaCode(betaCode);
}
async updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void> {
return this.betaCodeRepository.updateBetaCodeRedeemed(code, redeemerId, redeemedAt);
}
async deleteBetaCode(code: string, creatorId: UserID): Promise<void> {
return this.betaCodeRepository.deleteBetaCode(code, creatorId);
}
async deleteAllBetaCodes(userId: UserID): Promise<void> {
return this.betaCodeRepository.deleteAllBetaCodes(userId);
}
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,215 @@
/*
* 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 '~/BrandedTypes';
import {BatchBuilder, Db, deleteOneOrMany, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import type {NoteRow, RelationshipRow} from '~/database/CassandraTypes';
import {Relationship, UserNote} from '~/Models';
import {Notes, Relationships} from '~/Tables';
import type {IUserRelationshipRepository} from './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,
});
const FETCH_RELATIONSHIPS_FOR_DELETE_CQL = Relationships.selectCql({
columns: ['source_user_id', 'target_user_id', 'type'],
where: Relationships.where.eq('source_user_id', 'user_id'),
});
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> {
await deleteOneOrMany(
Relationships.deleteCql({
where: Relationships.where.eq('source_user_id', 'user_id'),
}),
{user_id: userId},
);
const relationships = await fetchMany<{source_user_id: bigint; target_user_id: bigint; type: number}>(
FETCH_RELATIONSHIPS_FOR_DELETE_CQL,
{user_id: userId},
);
const batch = new BatchBuilder();
for (const rel of relationships) {
if (rel.target_user_id === BigInt(userId)) {
batch.addPrepared(
Relationships.deleteByPk({
source_user_id: createUserID(rel.source_user_id),
target_user_id: createUserID(rel.target_user_id),
type: rel.type,
}),
);
}
}
await batch.execute();
}
async deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void> {
await deleteOneOrMany(
Relationships.deleteByPk({
source_user_id: sourceUserId,
target_user_id: targetUserId,
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 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((current?.version ?? 0) + 1),
},
}),
Relationships,
{onFailure: 'log'},
);
return new Relationship({
...relationship,
version: result.finalVersion ?? 1,
});
}
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((current?.version ?? 0) + 1)},
}),
Notes,
{onFailure: 'log'},
);
return new UserNote({
source_user_id: sourceUserId,
target_user_id: targetUserId,
note,
version: result.finalVersion ?? 0,
});
}
}

View File

@@ -0,0 +1,660 @@
/*
* 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 '~/BrandedTypes';
import type {
AuthSessionRow,
BetaCodeRow,
EmailRevertTokenRow,
EmailVerificationTokenRow,
GiftCodeRow,
PasswordResetTokenRow,
PaymentBySubscriptionRow,
PaymentRow,
PhoneTokenRow,
PushSubscriptionRow,
RecentMentionRow,
RelationshipRow,
UserGuildSettingsRow,
UserRow,
UserSettingsRow,
} from '~/database/CassandraTypes';
import type {
AuthSession,
BetaCode,
Channel,
EmailRevertToken,
EmailVerificationToken,
GiftCode,
MfaBackupCode,
PasswordResetToken,
Payment,
PushSubscription,
ReadState,
RecentMention,
Relationship,
SavedMessage,
User,
UserGuildSettings,
UserNote,
UserSettings,
VisionarySlot,
WebAuthnCredential,
} from '~/Models';
import {ReadStateRepository} from '~/read_state/ReadStateRepository';
import type {PrivateChannelSummary} from './IUserChannelRepository';
import type {IUserRepositoryAggregate} from './IUserRepositoryAggregate';
import {UserAccountRepository} from './UserAccountRepository';
import {UserAuthRepository} from './UserAuthRepository';
import {UserChannelRepository} from './UserChannelRepository';
import {UserContentRepository} from './UserContentRepository';
import {UserRelationshipRepository} from './UserRelationshipRepository';
import {UserSettingsRepository} from './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 | null> {
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 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): Promise<void> {
return this.authRepo.createIpAuthorizationToken(userId, token);
}
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 createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void> {
return this.authRepo.createPendingVerification(userId, createdAt, metadata);
}
async deletePendingVerification(userId: UserID): Promise<void> {
return this.authRepo.deletePendingVerification(userId);
}
async listRelationships(sourceUserId: UserID): Promise<Array<Relationship>> {
return this.relationshipRepo.listRelationships(sourceUserId);
}
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 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 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 listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>> {
return this.contentRepo.listBetaCodes(creatorId);
}
async getBetaCode(code: string): Promise<BetaCode | null> {
return this.contentRepo.getBetaCode(code);
}
async upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode> {
return this.contentRepo.upsertBetaCode(betaCode);
}
async updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void> {
return this.contentRepo.updateBetaCodeRedeemed(code, redeemerId, redeemedAt);
}
async deleteBetaCode(code: string, creatorId: UserID): Promise<void> {
return this.contentRepo.deleteBetaCode(code, creatorId);
}
async deleteAllBetaCodes(userId: UserID): Promise<void> {
return this.contentRepo.deleteAllBetaCodes(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,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 type {GuildID, UserID} from '~/BrandedTypes';
import {buildPatchFromData, deleteOneOrMany, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import type {UserGuildSettingsRow, UserSettingsRow} from '~/database/CassandraTypes';
import {USER_GUILD_SETTINGS_COLUMNS, USER_SETTINGS_COLUMNS} from '~/database/types/UserTypes';
import {UserGuildSettings, UserSettings} from '~/Models';
import {UserGuildSettings as UserGuildSettingsTable, UserSettings as UserSettingsTable} from '~/Tables';
import type {IUserSettingsRepository} from './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: 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),
(current) => ({
pk: {user_id: userId, guild_id: guildId},
patch: buildPatchFromData(settings, current, USER_GUILD_SETTINGS_COLUMNS, ['user_id', 'guild_id']),
}),
UserGuildSettingsTable,
{onFailure: 'log'},
);
return new UserGuildSettings({...settings, version: result.finalVersion ?? 1});
}
async upsertSettings(settings: UserSettingsRow): Promise<UserSettings> {
const userId = settings.user_id;
const result = await executeVersionedUpdate<UserSettingsRow, 'user_id'>(
() => this.findSettings(userId).then((s) => s?.toRow() ?? null),
(current) => ({
pk: {user_id: userId},
patch: buildPatchFromData(settings, current, USER_SETTINGS_COLUMNS, ['user_id']),
}),
UserSettingsTable,
{onFailure: 'log'},
);
return new UserSettings({...settings, version: result.finalVersion ?? 1});
}
}

View File

@@ -0,0 +1,114 @@
/*
* 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 '~/BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {VisionarySlotRow} from '~/database/CassandraTypes';
import {CannotShrinkReservedSlotsError} from '~/Errors';
import {VisionarySlot} from '~/Models';
import {VisionarySlots} from '~/Tables';
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 expandVisionarySlots(byCount: number): Promise<void> {
const existingSlots = await this.listVisionarySlots();
const maxSlotIndex = existingSlots.length > 0 ? Math.max(...existingSlots.map((s) => s.slotIndex)) : -1;
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 unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
const existingSlot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
slot_index: slotIndex,
});
if (!existingSlot || existingSlot.user_id !== userId) {
return;
}
await upsertOne(
VisionarySlots.upsertAll({
slot_index: slotIndex,
user_id: null,
}),
);
}
}

View File

@@ -0,0 +1,129 @@
/*
* 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 '~/BrandedTypes';
import {Db} from '~/database/Cassandra';
import type {UserRow} from '~/database/CassandraTypes';
import {User} from '~/Models';
import {UserDataRepository} from './crud/UserDataRepository';
import {UserIndexRepository} from './crud/UserIndexRepository';
import {UserSearchRepository} from './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 result = await this.dataRepo.upsertUserRow(data, oldData);
if (result.finalVersion === null) {
throw new Error(`Failed to update user ${data.user_id} after max retries due to concurrent updates`);
}
await this.indexRepo.syncIndices(data, oldData);
const user = new User(data);
await this.searchRepo.indexUser(user);
return user;
}
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User | null> {
if (!oldData) {
const existingUser = await this.findUnique(userId);
if (!existingUser) return null;
oldData = existingUser.toRow();
}
const newData: UserRow = {...oldData, ...patchData, user_id: userId};
const userPatch: Record<string, ReturnType<typeof Db.set> | ReturnType<typeof Db.clear>> = {};
for (const [key, value] of Object.entries(patchData)) {
if (key === 'user_id') continue;
if (value === undefined) 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);
if (result.finalVersion === null) {
throw new Error(`Failed to patch user ${userId} after max retries due to concurrent updates`);
}
await this.indexRepo.syncIndices(newData, oldData);
const user = new User(newData);
await this.searchRepo.indexUser(user);
return user;
}
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);
}
}

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 '~/BrandedTypes';
import {deleteOneOrMany, fetchMany, upsertOne} from '~/database/Cassandra';
import type {User} from '~/Models';
import {UsersPendingDeletion} from '~/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 '~/BrandedTypes';
import {BatchBuilder, fetchMany} from '~/database/Cassandra';
import type {GuildMemberByUserIdRow} from '~/database/CassandraTypes';
import {GuildMembers, GuildMembersByUserId} from '~/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,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 type {UserID} from '~/BrandedTypes';
import {fetchMany, fetchOne} from '~/database/Cassandra';
import type {
UserByEmailRow,
UserByPhoneRow,
UserByStripeCustomerIdRow,
UserByStripeSubscriptionIdRow,
UserByUsernameRow,
} from '~/database/CassandraTypes';
import type {User} from '~/Models';
import {UserByEmail, UserByPhone, UserByStripeCustomerId, UserByStripeSubscriptionId, UserByUsername} from '~/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,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 {createUserID, type UserID} from '~/BrandedTypes';
import {buildPatchFromData, Db, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import {EMPTY_USER_ROW, USER_COLUMNS, type UserRow} from '~/database/CassandraTypes';
import {User} from '~/Models';
import {Users} from '~/Tables';
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 createFetchAllUsersFirstPageCql = (limit: number) => 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]: import('~/database/Cassandra').DbOp<UserRow[K]>;
}>;
export class UserDataRepository {
async findUnique(userId: UserID): Promise<User | null> {
if (userId === 0n) {
return new User({
...EMPTY_USER_ROW,
user_id: createUserID(0n),
username: 'Fluxer',
discriminator: 0,
bot: true,
system: true,
});
}
if (userId === 1n) {
return new User({
...EMPTY_USER_ROW,
user_id: createUserID(1n),
username: 'Deleted User',
discriminator: 0,
bot: false,
system: true,
});
}
const user = await fetchOne<UserRow>(FETCH_USER_BY_ID_CQL, {user_id: userId});
return user ? new User(user) : null;
}
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;
let isFirstAttempt = true;
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
async () => {
if (isFirstAttempt && oldData !== undefined) {
isFirstAttempt = false;
return oldData;
}
isFirstAttempt = false;
const user = await this.findUnique(userId);
return user?.toRow() ?? null;
},
(current) => ({
pk: {user_id: userId},
patch: buildPatchFromData(data, current, USER_COLUMNS, ['user_id']),
}),
Users,
{onFailure: 'log'},
);
return {finalVersion: result.finalVersion};
}
async patchUser(userId: UserID, patch: UserPatch): Promise<{finalVersion: number | null}> {
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
async () => {
const user = await this.findUnique(userId);
return user?.toRow() ?? null;
},
(_current) => ({
pk: {user_id: userId},
patch,
}),
Users,
{onFailure: 'log'},
);
return {finalVersion: result.finalVersion};
}
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
const {userId, lastActiveAt, lastActiveIp} = params;
const patch: UserPatch = {
last_active_at: Db.set(lastActiveAt),
...(lastActiveIp !== undefined ? {last_active_ip: Db.set(lastActiveIp)} : {}),
};
await this.patchUser(userId, patch);
}
}

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 '~/BrandedTypes';
import {BatchBuilder} from '~/database/Cassandra';
import type {UserRow} from '~/database/CassandraTypes';
import {UserByEmail, UserByPhone, UserByStripeCustomerId, UserByStripeSubscriptionId, UserByUsername} from '~/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,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 {Logger} from '~/Logger';
import {getUserSearchService} from '~/Meilisearch';
import type {User} from '~/Models';
export class UserSearchRepository {
async indexUser(user: User): Promise<void> {
const userSearchService = getUserSearchService();
if (userSearchService) {
await userSearchService.indexUser(user).catch((error) => {
Logger.error({userId: user.id, error}, 'Failed to index user in search');
});
}
}
}

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 type {UserID} from '~/BrandedTypes';
import {BatchBuilder, Db, executeConditional, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {AuthSessionRow} from '~/database/CassandraTypes';
import {Logger} from '~/Logger';
import {AuthSession} from '~/Models';
import {AuthSessions, AuthSessionsByUserId} from '~/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 sessionResult = await executeConditional(AuthSessions.insertIfNotExists(sessionData));
if (!sessionResult.applied) {
const existingSession = await this.getAuthSessionByToken(sessionData.session_id_hash);
if (!existingSession) {
Logger.error(
{sessionIdHash: sessionData.session_id_hash},
'Failed to create or retrieve existing auth session',
);
throw new Error('Failed to create or retrieve existing auth session');
}
Logger.debug(
{sessionIdHash: sessionData.session_id_hash},
'Auth session already exists, returning existing session',
);
return existingSession;
}
try {
await upsertOne(
AuthSessionsByUserId.insert({
user_id: sessionData.user_id,
session_id_hash: sessionData.session_id_hash,
}),
);
} catch (error) {
Logger.error({sessionIdHash: sessionData.session_id_hash, error}, 'Failed to create AuthSessionsByUserId entry');
}
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 '~/database/Cassandra';
import type {EmailChangeTicketRow, EmailChangeTokenRow} from '~/database/CassandraTypes';
import {EmailChangeTickets, EmailChangeTokens} from '~/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,120 @@
/*
* 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 '~/BrandedTypes';
import {UserFlags} from '~/Constants';
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {AuthorizedIpRow, IpAuthorizationTokenRow} from '~/database/CassandraTypes';
import {AuthorizedIps, IpAuthorizationTokens, Users} from '~/Tables';
import type {IUserAccountRepository} from '../IUserAccountRepository';
export {IpAuthorizationTokens};
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): Promise<void> {
const user = await this.userAccountRepository.findUnique(userId);
await upsertOne(
IpAuthorizationTokens.insert({
token_: createIpAuthorizationToken(token),
user_id: userId,
email: user!.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 '~/BrandedTypes';
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, upsertOne} from '~/database/Cassandra';
import type {MfaBackupCodeRow} from '~/database/CassandraTypes';
import {MfaBackupCode} from '~/Models';
import {MfaBackupCodes} from '~/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,68 @@
/*
* 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 '~/BrandedTypes';
import {BatchBuilder, executeConditional, fetchOne, upsertOne} from '~/database/Cassandra';
import {PendingVerifications, PendingVerificationsByTime} from '~/Tables';
const FETCH_PENDING_VERIFICATION_CQL = PendingVerifications.selectCql({
where: PendingVerifications.where.eq('user_id'),
limit: 1,
});
export class PendingVerificationRepository {
async createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void> {
const verificationResult = await executeConditional(
PendingVerifications.insertIfNotExists({
user_id: userId,
created_at: createdAt,
version: 1,
metadata,
}),
);
if (!verificationResult.applied) {
return;
}
await upsertOne(
PendingVerificationsByTime.insert({
created_at: createdAt,
user_id: userId,
}),
);
}
async deletePendingVerification(userId: UserID): Promise<void> {
const pending = await fetchOne<{user_id: bigint; created_at: Date}>(FETCH_PENDING_VERIFICATION_CQL, {
user_id: userId,
});
if (!pending) return;
const batch = new BatchBuilder();
batch.addPrepared(PendingVerifications.deleteByPk({user_id: userId}));
batch.addPrepared(
PendingVerificationsByTime.deleteByPk({
created_at: pending.created_at,
user_id: userId,
}),
);
await batch.execute();
}
}

View File

@@ -0,0 +1,141 @@
/*
* 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 {
createEmailRevertToken,
createEmailVerificationToken,
createPasswordResetToken,
type PhoneVerificationToken,
type UserID,
} from '~/BrandedTypes';
import {deleteOneOrMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {
EmailRevertTokenRow,
EmailVerificationTokenRow,
PasswordResetTokenRow,
PhoneTokenRow,
} from '~/database/CassandraTypes';
import {EmailRevertToken, EmailVerificationToken, PasswordResetToken} from '~/Models';
import {EmailRevertTokens, EmailVerificationTokens, PasswordResetTokens, PhoneTokens} from '~/Tables';
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 = 900;
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 '~/BrandedTypes';
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {WebAuthnCredentialRow} from '~/database/CassandraTypes';
import {WebAuthnCredential} from '~/Models';
import {WebAuthnCredentialLookup, WebAuthnCredentials} from '~/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,49 @@
/*
* 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 '~/BrandedTypes';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {User} from '~/Models';
import {mapUserToPrivateResponse} from '~/user/UserModel';
import {invalidateUserCache} from '../UserCacheHelpers';
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,
});
}
}

View File

@@ -0,0 +1,96 @@
/*
* 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 '~/BrandedTypes';
import {InputValidationError} from '~/errors/InputValidationError';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {PackService} from '~/pack/PackService';
import type {z} from '~/Schema';
import type {IUserAccountRepository} from '~/user/repositories/IUserAccountRepository';
import type {CustomStatusPayload} from '~/user/UserTypes';
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: IGuildRepository,
private readonly packService: PackService,
) {}
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.create('custom_status.emoji_id', 'Custom emoji not found');
}
const user = await this.userAccountRepository.findUnique(userId);
if (!user?.canUseGlobalExpressions()) {
throw InputValidationError.create('custom_status.emoji_id', 'Premium required to use 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.create(
'custom_status.emoji_id',
'Cannot use this emoji without access to its guild or installed pack',
);
}
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,349 @@
/*
* 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 {InputValidationError, RateLimitError} from '~/Errors';
import type {IEmailService} from '~/infrastructure/IEmailService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import {Logger} from '~/Logger';
import type {User} from '~/Models';
import type {EmailChangeRepository} from '../repositories/auth/EmailChangeRepository';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
export interface StartEmailChangeResult {
ticket: string;
require_original: boolean;
original_email?: string | null;
original_proof?: string | null;
original_code_expires_at?: string;
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 = 10 * 60 * 1000;
private readonly NEW_CODE_TTL_MS = 10 * 60 * 1000;
private readonly TOKEN_TTL_MS = 30 * 60 * 1000;
private readonly RESEND_COOLDOWN_MS = 30 * 1000;
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.passwordHash;
const hasEmail = !!user.email;
if (!hasEmail && !isUnclaimed) {
throw InputValidationError.create('email', 'You must have an 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, 15 * 60 * 1000);
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,
original_proof: originalProof,
original_code_expires_at: originalCodeExpiresAt?.toISOString(),
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.create('ticket', 'Original email already verified.');
}
if (!row.original_email) {
throw InputValidationError.create('ticket', 'No original email on record.');
}
this.assertCooldown(row.original_code_sent_at);
await this.ensureRateLimit(`email_change:orig:${user.id}`, 3, 15 * 60 * 1000);
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.create('ticket', 'Original verification not required for this flow.');
}
if (row.original_verified && row.original_proof) {
return {original_proof: row.original_proof};
}
if (!row.original_code || !row.original_code_expires_at) {
throw InputValidationError.create('code', 'Verification code not issued.');
}
if (row.original_code_expires_at.getTime() < Date.now()) {
throw InputValidationError.create('code', 'Verification code expired.');
}
if (row.original_code !== code.trim()) {
throw InputValidationError.create('code', '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.create('ticket', 'Original email must be verified first.');
}
if (row.original_proof !== originalProof) {
throw InputValidationError.create('original_proof', 'Invalid proof token.');
}
const trimmedEmail = newEmail.trim();
if (!trimmedEmail) {
throw InputValidationError.create('new_email', 'Email is required.');
}
if (row.original_email && trimmedEmail.toLowerCase() === row.original_email.toLowerCase()) {
throw InputValidationError.create('new_email', 'New email must be different.');
}
const existing = await this.userAccountRepository.findByEmail(trimmedEmail.toLowerCase());
if (existing && existing.id !== user.id) {
throw InputValidationError.create('new_email', 'Email already in use.');
}
this.assertCooldown(row.new_code_sent_at);
await this.ensureRateLimit(`email_change:new:${user.id}`, 5, 15 * 60 * 1000);
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.create('ticket', 'No new email requested.');
}
this.assertCooldown(row.new_code_sent_at);
await this.ensureRateLimit(`email_change:new:${user.id}`, 5, 15 * 60 * 1000);
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.create('ticket', 'Original email must be verified first.');
}
if (row.original_proof !== originalProof) {
throw InputValidationError.create('original_proof', 'Invalid proof token.');
}
if (!row.new_email || !row.new_code || !row.new_code_expires_at) {
throw InputValidationError.create('code', 'Verification code not issued.');
}
if (row.new_code_expires_at.getTime() < Date.now()) {
throw InputValidationError.create('code', 'Verification code expired.');
}
if (row.new_code !== code.trim()) {
throw InputValidationError.create('code', '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.create('email_token', 'Invalid email token.');
}
if (row.expires_at.getTime() < Date.now()) {
await this.repo.deleteToken(token).catch((error) => Logger.warn({error}, 'Failed to delete expired email token'));
throw InputValidationError.create('email_token', 'Email token expired.');
}
await this.repo.deleteToken(token);
return row.new_email;
}
private async getTicketForUser(ticket: string, userId: bigint) {
const row = await this.repo.findTicket(ticket);
if (!row || row.user_id !== userId) {
throw InputValidationError.create('ticket', 'Invalid or expired ticket.');
}
if (row.status === 'completed') {
throw InputValidationError.create('ticket', '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 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: maxAttempts,
resetTime: new Date(Date.now() + (result.retryAfter || 0) * 1000),
});
}
}
}

View File

@@ -0,0 +1,106 @@
/*
* 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 '~/auth/AuthService';
import type {UserID} from '~/BrandedTypes';
import {Config} from '~/Config';
import {DeletionReasons, UserFlags} from '~/Constants';
import {UnknownUserError, UserOwnsGuildsError} from '~/Errors';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {IEmailService} from '~/infrastructure/IEmailService';
import type {RedisAccountDeletionQueueService} from '~/infrastructure/RedisAccountDeletionQueueService';
import {hasPartialUserFieldsChanged} from '~/user/UserMappers';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
import type {UserAccountUpdatePropagator} from './UserAccountUpdatePropagator';
interface UserAccountLifecycleServiceDeps {
userAccountRepository: IUserAccountRepository;
guildRepository: IGuildRepository;
authService: AuthService;
emailService: IEmailService;
updatePropagator: UserAccountUpdatePropagator;
redisDeletionQueue: RedisAccountDeletionQueueService;
}
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 ownedGuildIds = await this.deps.guildRepository.listOwnedGuildIds(userId);
if (ownedGuildIds.length > 0) {
throw new UserOwnsGuildsError();
}
const updatedUser = await this.deps.userAccountRepository.patchUpsert(userId, {
flags: user.flags | UserFlags.DISABLED,
});
await this.deps.authService.terminateAllUserSessions(userId);
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser!);
if (hasPartialUserFieldsChanged(user, updatedUser!)) {
await this.deps.updatePropagator.invalidateUserCache(userId);
}
}
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 * 60 * 60 * 1000;
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,
});
await this.deps.userAccountRepository.addPendingDeletion(userId, pendingDeletionAt, DeletionReasons.USER_REQUESTED);
await this.deps.redisDeletionQueue.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);
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser!);
if (hasPartialUserFieldsChanged(user, updatedUser!)) {
await this.deps.updatePropagator.invalidateUserCache(userId);
}
}
}

View File

@@ -0,0 +1,254 @@
/*
* 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 '~/BrandedTypes';
import {ChannelTypes, RelationshipTypes, UserFlags, UserPremiumTypes} from '~/Constants';
import {MissingAccessError, UnknownUserError} from '~/Errors';
import type {GuildMemberResponse} from '~/guild/GuildModel';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {GuildService} from '~/guild/services/GuildService';
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
import type {GuildMember, User} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
import type {IUserChannelRepository} from '../repositories/IUserChannelRepository';
import type {IUserRelationshipRepository} from '../repositories/IUserRelationshipRepository';
interface UserAccountLookupServiceDeps {
userAccountRepository: IUserAccountRepository;
userChannelRepository: IUserChannelRepository;
userRelationshipRepository: IUserRelationshipRepository;
guildRepository: IGuildRepository;
guildService: GuildService;
discriminatorService: IDiscriminatorService;
}
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}>;
}> {
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;
}
}
let mutualFriends: Array<User> | undefined;
if (withMutualFriends && userId !== targetId) {
mutualFriends = await this.getMutualFriends(userId, targetId);
}
let mutualGuilds: Array<{id: string; nick: string | null}> | undefined;
if (withMutualGuilds && userId !== targetId) {
mutualGuilds = await this.getMutualGuilds(userId, targetId);
}
return {
user,
guildMember,
guildMemberDomain,
premiumType,
premiumSince,
premiumLifetimeSequence,
mutualFriends,
mutualGuilds,
};
}
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;
}
}

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 '~/BrandedTypes';
import {UnknownUserError} from '~/Errors';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
import type {IUserRelationshipRepository} from '../repositories/IUserRelationshipRepository';
import type {UserAccountUpdatePropagator} from './UserAccountUpdatePropagator';
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,431 @@
/*
* 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 {UserFlags} from '~/Constants';
import type {PartialRowUpdate, UserRow} from '~/database/CassandraTypes';
import {InputValidationError, MissingAccessError} from '~/Errors';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import type {User} from '~/Models';
import type {UserUpdateRequest} from '~/user/UserModel';
import {deriveDominantAvatarColor} from '~/utils/AvatarColorUtils';
import * as EmojiUtils from '~/utils/EmojiUtils';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
import type {UserAccountUpdatePropagator} from './UserAccountUpdatePropagator';
interface UserFieldUpdates extends PartialRowUpdate<UserRow> {
invalidateAuthSessions?: boolean;
}
export interface ProfileUpdateResult {
updates: UserFieldUpdates;
preparedAvatarUpload: PreparedAssetUpload | null;
preparedBannerUpload: PreparedAssetUpload | null;
}
interface UserAccountProfileServiceDeps {
userAccountRepository: IUserAccountRepository;
guildRepository: IGuildRepository;
entityAssetService: EntityAssetService;
rateLimitService: IRateLimitService;
updatePropagator: UserAccountUpdatePropagator;
}
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,
};
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, 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) {
const bioRateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `bio_change:${user.id}`,
maxAttempts: 25,
windowMs: 30 * 60 * 1000,
});
if (!bioRateLimit.allowed) {
const minutes = Math.ceil((bioRateLimit.retryAfter || 0) / 60);
throw InputValidationError.create(
'bio',
`You've changed your bio too many times recently. Please try again in ${minutes} minutes.`,
);
}
if (bio && bio.length > 160 && !user.isPremium()) {
throw InputValidationError.create('bio', 'Bio longer than 160 characters requires premium');
}
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,
});
}
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) {
const pronounsRateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `pronouns_change:${user.id}`,
maxAttempts: 25,
windowMs: 30 * 60 * 1000,
});
if (!pronounsRateLimit.allowed) {
const minutes = Math.ceil((pronounsRateLimit.retryAfter || 0) / 60);
throw InputValidationError.create(
'pronouns',
`You've changed your pronouns too many times recently. Please try again in ${minutes} 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) {
const accentColorRateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `accent_color_change:${user.id}`,
maxAttempts: 25,
windowMs: 30 * 60 * 1000,
});
if (!accentColorRateLimit.allowed) {
const minutes = Math.ceil((accentColorRateLimit.retryAfter || 0) / 60);
throw InputValidationError.create(
'accent_color',
`You've changed your accent color too many times recently. Please try again in ${minutes} minutes.`,
);
}
updates.accent_color = accentColor;
}
}
private async processAvatarUpdate(params: {
user: User;
avatar: string | null;
updates: UserFieldUpdates;
}): Promise<PreparedAssetUpload | null> {
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: 30 * 60 * 1000,
});
if (!avatarRateLimit.allowed) {
const minutes = Math.ceil((avatarRateLimit.retryAfter || 0) / 60);
throw InputValidationError.create(
'avatar',
`You've changed your avatar too many times recently. Please try again in ${minutes} minutes.`,
);
}
const prepared = await this.deps.entityAssetService.prepareAssetUpload({
assetType: 'avatar',
entityType: 'user',
entityId: user.id,
previousHash: user.avatarHash,
base64Image: avatar,
errorPath: 'avatar',
});
if (prepared.isAnimated && !user.isPremium()) {
await this.deps.entityAssetService.rollbackAssetUpload(prepared);
throw InputValidationError.create('avatar', '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;
}
if (banner && !user.isPremium()) {
throw InputValidationError.create('banner', 'Banners require premium');
}
const bannerRateLimit = await this.deps.rateLimitService.checkLimit({
identifier: `banner_change:${user.id}`,
maxAttempts: 25,
windowMs: 30 * 60 * 1000,
});
if (!bannerRateLimit.allowed) {
const minutes = Math.ceil((bannerRateLimit.retryAfter || 0) / 60);
throw InputValidationError.create(
'banner',
`You've changed your banner too many times recently. Please try again in ${minutes} minutes.`,
);
}
const prepared = await this.deps.entityAssetService.prepareAssetUpload({
assetType: 'banner',
entityType: 'user',
entityId: user.id,
previousHash: user.bannerHash,
base64Image: banner,
errorPath: 'banner',
});
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,281 @@
/*
* 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 {uint8ArrayToBase64} from 'uint8array-extras';
import type {AuthService} from '~/auth/AuthService';
import type {SudoVerificationResult} from '~/auth/services/SudoVerificationService';
import {userHasMfa} from '~/auth/services/SudoVerificationService';
import {UserPremiumTypes} from '~/Constants';
import type {PartialRowUpdate, UserRow} from '~/database/CassandraTypes';
import {InputValidationError} from '~/Errors';
import {SudoModeRequiredError} from '~/errors/SudoModeRequiredError';
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import type {AuthSession, User} from '~/Models';
import type {UserUpdateRequest} from '~/user/UserModel';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
interface UserFieldUpdates extends PartialRowUpdate<UserRow> {
invalidateAuthSessions?: boolean;
}
interface UserAccountSecurityServiceDeps {
userAccountRepository: IUserAccountRepository;
authService: AuthService;
discriminatorService: IDiscriminatorService;
rateLimitService: IRateLimitService;
}
export class UserAccountSecurityService {
constructor(private readonly deps: UserAccountSecurityServiceDeps) {}
async processSecurityUpdates(params: {
user: User;
data: UserUpdateRequest;
sudoContext?: SudoVerificationResult;
}): Promise<UserFieldUpdates> {
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,
invalidateAuthSessions: false,
};
const isUnclaimedAccount = !user.passwordHash;
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();
updates.invalidateAuthSessions = false;
} else if (data.new_password) {
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
throw new SudoModeRequiredError(hasMfa);
}
updates.password_hash = await this.hashNewPassword(data.new_password);
updates.password_last_changed_at = new Date();
updates.invalidateAuthSessions = true;
}
if (data.username) {
const {newUsername, newDiscriminator} = await this.updateUsername({
user,
username: data.username,
requestedDiscriminator: data.discriminator,
});
updates.username = newUsername;
updates.discriminator = newDiscriminator;
} else if (data.discriminator) {
updates.discriminator = await this.updateDiscriminator({user, discriminator: data.discriminator});
}
if (user.isBot) {
updates.global_name = null;
} else if (data.global_name !== undefined) {
updates.global_name = data.global_name;
}
if (rawEmail) {
if (normalizedEmail !== user.email?.toLowerCase()) {
const existing = await this.deps.userAccountRepository.findByEmail(normalizedEmail!);
if (existing && existing.id !== user.id) {
throw InputValidationError.create('email', 'Email already in use');
}
}
updates.email = rawEmail;
}
return updates;
}
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.create('new_password', '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: 60 * 60 * 1000,
});
if (!rateLimit.allowed) {
const minutes = Math.ceil((rateLimit.retryAfter || 0) / 60);
throw InputValidationError.create(
'username',
`You've changed your username too many times recently. Please try again in ${minutes} minutes.`,
);
}
const isPremium = user.isPremium();
if (
!isPremium &&
user.username === username &&
(normalizedRequestedDiscriminator === undefined || normalizedRequestedDiscriminator === user.discriminator)
) {
return {
newUsername: user.username,
newDiscriminator: user.discriminator,
};
}
if (!isPremium) {
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
username,
requestedDiscriminator: undefined,
isPremium: false,
});
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
throw InputValidationError.create(
'username',
'Too many users with this username. Please try a different username.',
);
}
return {
newUsername: username,
newDiscriminator: discriminatorResult.discriminator,
};
}
if (user.premiumType !== UserPremiumTypes.LIFETIME) {
if (requestedDiscriminator === 0) {
throw InputValidationError.create(
'discriminator',
'You must be on the Visionary lifetime plan to use that discriminator.',
);
}
}
const discriminatorToUse = normalizedRequestedDiscriminator ?? user.discriminator;
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
username,
requestedDiscriminator: discriminatorToUse,
isPremium,
});
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
throw InputValidationError.create(
'username',
discriminatorToUse !== undefined
? 'This tag is already taken'
: 'Too many users with this username. Please try a different username.',
);
}
return {
newUsername: username,
newDiscriminator: discriminatorResult.discriminator,
};
}
private async updateDiscriminator({user, discriminator}: {user: User; discriminator: number}): Promise<number> {
if (!user.isPremium()) {
throw InputValidationError.create('discriminator', 'Changing discriminator requires premium');
}
if (user.premiumType !== UserPremiumTypes.LIFETIME && discriminator === 0) {
throw InputValidationError.create(
'discriminator',
'You must be on the Visionary lifetime plan to use that discriminator.',
);
}
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
username: user.username,
requestedDiscriminator: discriminator,
isPremium: true,
});
if (!discriminatorResult.available) {
throw InputValidationError.create('discriminator', 'This tag is already taken');
}
return discriminator;
}
}

View File

@@ -0,0 +1,294 @@
/*
* 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 '~/auth/AuthService';
import type {SudoVerificationResult} from '~/auth/services/SudoVerificationService';
import type {GuildID, UserID} from '~/BrandedTypes';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {GuildService} from '~/guild/services/GuildService';
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
import type {EntityAssetService} from '~/infrastructure/EntityAssetService';
import type {IEmailService} from '~/infrastructure/IEmailService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import type {RedisAccountDeletionQueueService} from '~/infrastructure/RedisAccountDeletionQueueService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import {Logger} from '~/Logger';
import type {AuthSession, User, UserGuildSettings, UserSettings} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {PackService} from '~/pack/PackService';
import {hasPartialUserFieldsChanged} from '~/user/UserMappers';
import type {UserGuildSettingsUpdateRequest, UserSettingsUpdateRequest, UserUpdateRequest} from '~/user/UserModel';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
import type {IUserChannelRepository} from '../repositories/IUserChannelRepository';
import type {IUserRelationshipRepository} from '../repositories/IUserRelationshipRepository';
import type {IUserSettingsRepository} from '../repositories/IUserSettingsRepository';
import {UserAccountLifecycleService} from './UserAccountLifecycleService';
import {UserAccountLookupService} from './UserAccountLookupService';
import {UserAccountNotesService} from './UserAccountNotesService';
import {UserAccountProfileService} from './UserAccountProfileService';
import {UserAccountSecurityService} from './UserAccountSecurityService';
import {UserAccountSettingsService} from './UserAccountSettingsService';
import {UserAccountUpdatePropagator} from './UserAccountUpdatePropagator';
import type {UserContactChangeLogService} from './UserContactChangeLogService';
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;
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: IGuildRepository,
discriminatorService: IDiscriminatorService,
redisDeletionQueue: RedisAccountDeletionQueueService,
private readonly contactChangeLogService: UserContactChangeLogService,
) {
this.updatePropagator = new UserAccountUpdatePropagator({
userCacheService,
gatewayService,
mediaService,
});
this.lookupService = new UserAccountLookupService({
userAccountRepository,
userRelationshipRepository,
userChannelRepository,
guildRepository,
guildService,
discriminatorService,
});
this.profileService = new UserAccountProfileService({
userAccountRepository,
guildRepository,
entityAssetService,
rateLimitService,
updatePropagator: this.updatePropagator,
});
this.securityService = new UserAccountSecurityService({
userAccountRepository,
authService,
discriminatorService,
rateLimitService,
});
this.settingsService = new UserAccountSettingsService({
userAccountRepository,
userSettingsRepository,
updatePropagator: this.updatePropagator,
guildRepository,
packService,
});
this.notesService = new UserAccountNotesService({
userAccountRepository,
userRelationshipRepository,
updatePropagator: this.updatePropagator,
});
this.lifecycleService = new UserAccountLifecycleService({
userAccountRepository,
guildRepository,
authService,
emailService,
updatePropagator: this.updatePropagator,
redisDeletionQueue,
});
}
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 securityUpdates = await this.securityService.processSecurityUpdates({user, data, sudoContext});
const updates = {
...securityUpdates,
...profileResult.updates,
};
const updatedUserRow = {
...user.toRow(),
...updates,
};
if (updates.avatar_hash === null) {
updatedUserRow.avatar_hash = null;
}
if (updates.banner_hash === null) {
updatedUserRow.banner_hash = null;
}
const emailChanged = data.email !== undefined;
if (emailChanged) {
updatedUserRow.email_verified = !!emailVerifiedViaToken;
}
let updatedUser: User;
try {
updatedUser = await this.userAccountRepository.upsert(updatedUserRow, 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,
});
try {
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.invalidateUserCache(updatedUser.id);
}
if (updates.invalidateAuthSessions) {
await this.securityService.invalidateAndRecreateSessions({user, oldAuthSession, request});
}
return updatedUser;
}
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 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,273 @@
/*
* 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 '~/BrandedTypes';
import {FriendSourceFlags, GroupDmAddPermissionFlags, IncomingCallFlags, UserNotificationSettings} from '~/Constants';
import type {ChannelOverride, UserGuildSettingsRow} from '~/database/types/UserTypes';
import {UnknownUserError} from '~/Errors';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {UserGuildSettings, UserSettings} from '~/Models';
import type {PackService} from '~/pack/PackService';
import type {UserGuildSettingsUpdateRequest, UserSettingsUpdateRequest} from '~/user/UserModel';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
import type {IUserSettingsRepository} from '../repositories/IUserSettingsRepository';
import {CustomStatusValidator} from './CustomStatusValidator';
import type {UserAccountUpdatePropagator} from './UserAccountUpdatePropagator';
interface UserAccountSettingsServiceDeps {
userAccountRepository: IUserAccountRepository;
userSettingsRepository: IUserSettingsRepository;
updatePropagator: UserAccountUpdatePropagator;
guildRepository: IGuildRepository;
packService: PackService;
}
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,
);
}
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) 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.guild_positions !== undefined) {
updatedRowData.guild_positions = data.guild_positions ? data.guild_positions.map(createGuildID) : null;
}
if (data.restricted_guilds !== undefined) {
updatedRowData.restricted_guilds = data.restricted_guilds
? new Set(data.restricted_guilds.map(createGuildID))
: null;
}
if (data.default_guilds_restricted !== undefined) {
updatedRowData.default_guilds_restricted = data.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) {
updatedRowData.guild_folders = data.guild_folders.map((folder) => ({
folder_id: folder.id,
name: folder.name,
color: folder.color ?? 0x000000,
guild_ids: folder.guild_ids.map(createGuildID),
}));
}
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;
const updatedSettings = await this.deps.userSettingsRepository.upsertSettings(updatedRowData);
await this.deps.updatePropagator.dispatchUserSettingsUpdate({userId, settings: updatedSettings});
if (localeChanged) {
const updatedUser = await this.deps.userAccountRepository.patchUpsert(userId, {locale: data.locale});
if (updatedUser) {
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,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 type {UserID} from '~/BrandedTypes';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {UserGuildSettings, UserSettings} from '~/Models';
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '~/user/UserModel';
import {BaseUserUpdatePropagator} from './BaseUserUpdatePropagator';
interface UserAccountUpdatePropagatorDeps {
userCacheService: UserCacheService;
gatewayService: IGatewayService;
mediaService: IMediaService;
}
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> {
await this.deps.gatewayService.dispatchPresence({
userId,
event: 'USER_SETTINGS_UPDATE',
data: mapUserSettingsToResponse({settings}),
});
}
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,180 @@
/*
* 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 '~/auth/AuthService';
import type {SudoVerificationResult} from '~/auth/services/SudoVerificationService';
import {userHasMfa} from '~/auth/services/SudoVerificationService';
import {createEmailVerificationToken} from '~/BrandedTypes';
import {UserAuthenticatorTypes} from '~/Constants';
import {InputValidationError, MfaNotDisabledError, MfaNotEnabledError} from '~/Errors';
import {SudoModeRequiredError} from '~/errors/SudoModeRequiredError';
import type {IEmailService} from '~/infrastructure/IEmailService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {MfaBackupCode, User} from '~/Models';
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
import {mapUserToPrivateResponse} from '~/user/UserModel';
import * as RandomUtils from '~/utils/RandomUtils';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
import type {IUserAuthRepository} from '../repositories/IUserAuthRepository';
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}): Promise<Array<MfaBackupCode>> {
const {user, secret, code} = params;
if (user.totpSecret) throw new MfaNotDisabledError();
const userId = user.id;
if (!(await this.authService.verifyMfaCode({userId: user.id, mfaSecret: secret, code}))) {
throw InputValidationError.create('code', '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,
});
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.create('code', '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,
});
await this.userAuthRepository.clearMfaBackupCodes(userId);
await this.dispatchUserUpdate(updatedUser!);
if (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,
});
await this.userAuthRepository.deleteEmailVerificationToken(token);
if (updatedUser) {
await this.dispatchUserUpdate(updatedUser);
}
return true;
}
async resendVerificationEmail(user: User): Promise<boolean> {
if (user.emailVerified) {
return true;
}
const verificationToken = createEmailVerificationToken(RandomUtils.randomString(64));
await this.userAuthRepository.createEmailVerificationToken({
token_: verificationToken,
user_id: user.id,
email: user.email!,
});
await this.emailService.sendEmailVerification(user.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,487 @@
/*
* 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, createMessageID, createUserID, type UserID} from '~/BrandedTypes';
import {ChannelTypes, MAX_GROUP_DMS_PER_USER, MessageTypes, RelationshipTypes} from '~/Constants';
import {mapChannelToResponse} from '~/channel/ChannelModel';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import type {ChannelService} from '~/channel/services/ChannelService';
import {dispatchMessageCreate} from '~/channel/services/group_dm/GroupDmHelpers';
import {
CannotSendMessagesToUserError,
InputValidationError,
MaxGroupDmRecipientsError,
MaxGroupDmsError,
MissingAccessError,
NotFriendsWithUserError,
UnclaimedAccountRestrictedError,
UnknownUserError,
} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Channel, Message, User} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {CreatePrivateChannelRequest} from '~/user/UserModel';
import * as BucketUtils from '~/utils/BucketUtils';
import type {UserPermissionUtils} from '~/utils/UserPermissionUtils';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
import type {IUserChannelRepository} from '../repositories/IUserChannelRepository';
import type {IUserRelationshipRepository} from '../repositories/IUserRelationshipRepository';
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,
) {}
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,
});
}
const recipientId = createUserID(data.recipient_id!);
if (userId === recipientId) {
throw InputValidationError.create('recipient_id', 'Cannot DM yourself');
}
const targetUser = await this.userAccountRepository.findUnique(recipientId);
if (!targetUser) throw new UnknownUserError();
await this.validateDmPermission(userId, recipientId, targetUser);
const existingChannel = await this.userChannelRepository.findExistingDmState(userId, recipientId);
if (existingChannel) {
return await this.reopenExistingDMChannel({userId, existingChannel, userCacheService, requestCache});
}
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.create('channel_id', 'Channel must be a 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.create('channel_id', 'Channel must be a 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.create('channels', 'Cannot preload more than 100 channels at once');
}
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 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(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> {
if (recipients.length > 9) {
throw new MaxGroupDmRecipientsError();
}
const recipientIds = recipients.map(createUserID);
const uniqueRecipientIds = new Set(recipientIds);
if (uniqueRecipientIds.size !== recipientIds.length) {
throw InputValidationError.create('recipients', 'Duplicate recipients are not allowed');
}
if (uniqueRecipientIds.has(userId)) {
throw InputValidationError.create('recipients', 'Cannot add yourself to a 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(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(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;
if (openGroupDms >= MAX_GROUP_DMS_PER_USER) {
throw new MaxGroupDmsError();
}
}
private async validateDmPermission(userId: UserID, recipientId: UserID, recipientUser?: User | null): Promise<void> {
const senderUser = await this.userAccountRepository.findUnique(userId);
if (senderUser && !senderUser.passwordHash && !senderUser.isBot) {
throw new UnclaimedAccountRestrictedError('send direct messages');
}
const resolvedRecipient = recipientUser ?? (await this.userAccountRepository.findUnique(recipientId));
if (resolvedRecipient && !resolvedRecipient.passwordHash && !resolvedRecipient.isBot) {
throw new UnclaimedAccountRestrictedError('receive direct messages');
}
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();
}
}

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 '~/BrandedTypes';
import type {UserContactChangeLogRow} from '~/database/CassandraTypes';
import type {User} from '~/Models';
import type {UserContactChangeLogRepository} from '../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,579 @@
/*
* 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, createBetaCode, type MessageID, type UserID} from '~/BrandedTypes';
import {Config} from '~/Config';
import {MAX_BOOKMARKS_NON_PREMIUM, MAX_BOOKMARKS_PREMIUM} from '~/Constants';
import {mapMessageToResponse} from '~/channel/ChannelModel';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import type {ChannelService} from '~/channel/services/ChannelService';
import type {PushSubscriptionRow} from '~/database/CassandraTypes';
import {
BetaCodeAllowanceExceededError,
BetaCodeMaxUnclaimedError,
HarvestExpiredError,
HarvestFailedError,
HarvestNotReadyError,
HarvestOnCooldownError,
MaxBookmarksError,
MissingPermissionsError,
UnknownChannelError,
UnknownHarvestError,
UnknownMessageError,
UnknownUserError,
} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {IStorageService} from '~/infrastructure/IStorageService';
import type {RedisBulkMessageDeletionQueueService} from '~/infrastructure/RedisBulkMessageDeletionQueueService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import {Logger} from '~/Logger';
import type {BetaCode, Message, PushSubscription} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import * as RandomUtils from '~/utils/RandomUtils';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
import type {IWorkerService} from '~/worker/IWorkerService';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
import type {IUserContentRepository} from '../repositories/IUserContentRepository';
import {UserHarvest, type UserHarvestResponse} from '../UserHarvestModel';
import {UserHarvestRepository} from '../UserHarvestRepository';
import type {SavedMessageStatus} from '../UserTypes';
import {BaseUserUpdatePropagator} from './BaseUserUpdatePropagator';
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: RedisBulkMessageDeletionQueueService,
) {
this.updatePropagator = new BaseUserUpdatePropagator({
userCacheService,
gatewayService: this.gatewayService,
});
}
async getBetaCodes(userId: UserID): Promise<Array<BetaCode>> {
const betaCodes = await this.userContentRepository.listBetaCodes(userId);
const now = new Date();
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return betaCodes.filter((code) => {
if (!code.redeemedAt) {
return true;
}
return code.redeemedAt >= oneWeekAgo;
});
}
async getBetaCodeAllowanceInfo(userId: UserID): Promise<{allowance: number; nextResetAt: Date | null}> {
const user = await this.userAccountRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const now = new Date();
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
let allowance = user.betaCodeAllowance;
let lastResetAt = user.betaCodeLastResetAt;
if (!lastResetAt || lastResetAt < oneWeekAgo) {
allowance = 3;
lastResetAt = now;
}
const nextResetAt = lastResetAt ? new Date(lastResetAt.getTime() + 7 * 24 * 60 * 60 * 1000) : null;
return {allowance, nextResetAt};
}
async createBetaCode(userId: UserID): Promise<BetaCode> {
const user = await this.userAccountRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const existingBetaCodes = await this.userContentRepository.listBetaCodes(userId);
const unclaimedCount = existingBetaCodes.filter((code) => !code.redeemerId).length;
if (unclaimedCount >= 6) {
throw new BetaCodeMaxUnclaimedError();
}
const now = new Date();
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
let allowance = user.betaCodeAllowance;
let lastResetAt = user.betaCodeLastResetAt;
if (!lastResetAt || lastResetAt < oneWeekAgo) {
allowance = 3;
lastResetAt = now;
}
if (allowance <= 0) {
throw new BetaCodeAllowanceExceededError();
}
await this.userAccountRepository.patchUpsert(userId, {
beta_code_allowance: allowance - 1,
beta_code_last_reset_at: lastResetAt,
});
return await this.userContentRepository.upsertBetaCode({
code: createBetaCode(RandomUtils.randomString(32)),
created_at: now,
creator_id: userId,
redeemed_at: null,
redeemer_id: null,
version: 1,
});
}
async deleteBetaCode(params: {userId: UserID; code: string}): Promise<void> {
const {userId, code} = params;
const user = await this.userAccountRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const now = new Date();
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
let allowance = user.betaCodeAllowance;
let lastResetAt = user.betaCodeLastResetAt;
if (!lastResetAt || lastResetAt < oneWeekAgo) {
allowance = 3;
lastResetAt = now;
}
await this.userContentRepository.deleteBetaCode(code, userId);
await this.userAccountRepository.patchUpsert(userId, {
beta_code_allowance: Math.min(allowance + 1, 3),
beta_code_last_reset_at: lastResetAt,
});
}
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<{
channelId: ChannelID;
messageId: MessageID;
status: SavedMessageStatus;
message: Message | null;
}>
> {
const savedMessages = await this.userContentRepository.listSavedMessages(userId, limit);
const results: Array<{
channelId: ChannelID;
messageId: MessageID;
status: SavedMessageStatus;
message: Message | null;
}> = [];
for (const savedMessage of savedMessages) {
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);
continue;
}
if (error instanceof MissingPermissionsError || error instanceof UnknownChannelError) {
status = 'missing_permissions';
} else {
throw error;
}
}
results.push({
channelId: savedMessage.channelId,
messageId: savedMessage.messageId,
status,
message,
});
}
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 maxBookmarks = user.isPremium() ? MAX_BOOKMARKS_PREMIUM : MAX_BOOKMARKS_NON_PREMIUM;
if (savedMessages.length >= maxBookmarks) {
throw new MaxBookmarksError(user.isPremium());
}
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<{harvestId: 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() - 7 * 24 * 60 * 60 * 1000);
if (latestHarvest.requestedAt > sevenDaysAgo) {
const retryAfter = new Date(latestHarvest.requestedAt.getTime() + 7 * 24 * 60 * 60 * 1000);
throw new HarvestOnCooldownError({retryAfter});
}
}
}
const harvestId = 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 {harvestId: harvestId.toString()};
}
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<{downloadUrl: string; expiresAt: 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_SECONDS = 7 * 24 * 60 * 60;
const downloadUrl = await storageService.getPresignedDownloadURL({
bucket: Config.s3.buckets.harvests,
key: harvest.storageKey,
expiresIn: ZIP_EXPIRY_SECONDS,
});
const expiresAt = new Date(Date.now() + ZIP_EXPIRY_SECONDS * 1000);
return {
downloadUrl,
expiresAt: expiresAt.toISOString(),
};
}
async requestBulkMessageDeletion(params: {userId: UserID; delayMs?: number}): Promise<void> {
const {userId, delayMs = 24 * 60 * 60 * 1000} = params;
const scheduledAt = new Date(Date.now() + delayMs);
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,
});
if (!updatedUser) {
throw new UnknownUserError();
}
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 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,
});
if (!updatedUser) {
throw new UnknownUserError();
}
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 lastChannelId: ChannelID | undefined;
let lastMessageId: MessageID | undefined;
const channels = new Set<string>();
let messageCount = 0;
while (true) {
const messageRefs = await this.channelRepository.listMessagesByAuthor(
userId,
CHUNK_SIZE,
lastChannelId,
lastMessageId,
);
if (messageRefs.length === 0) {
break;
}
for (const {channelId, messageId} of messageRefs) {
if (SnowflakeUtils.extractTimestamp(messageId) > cutoffMs) {
continue;
}
channels.add(channelId.toString());
messageCount++;
}
lastChannelId = messageRefs[messageRefs.length - 1].channelId;
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,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 type {Redis} from 'ioredis';
import type {UserID} from '~/BrandedTypes';
import {Config} from '~/Config';
import {UserFlags} from '~/Constants';
import type {User} from '~/models/User';
export class UserDeletionEligibilityService {
private readonly INACTIVITY_WARNING_TTL_DAYS = 30;
private readonly INACTIVITY_WARNING_PREFIX = 'inactivity_warning_sent';
constructor(private redis: Redis) {}
async isEligibleForInactivityDeletion(user: User): Promise<boolean> {
if (user.isBot) {
return false;
}
if (user.isSystem) {
return false;
}
// Check: User must not have APP_STORE_REVIEWER flag set
if ((user.flags & UserFlags.APP_STORE_REVIEWER) !== 0n) {
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.getWarningRedisKey(userId);
const ttlSeconds = (this.INACTIVITY_WARNING_TTL_DAYS + 5) * 24 * 60 * 60;
const timestamp = Date.now().toString();
await this.redis.setex(key, ttlSeconds, timestamp);
}
async hasWarningSent(userId: UserID): Promise<boolean> {
const key = this.getWarningRedisKey(userId);
const exists = await this.redis.exists(key);
return exists === 1;
}
async getWarningSentTimestamp(userId: UserID): Promise<number | null> {
const key = this.getWarningRedisKey(userId);
const value = await this.redis.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 * 24 * 60 * 60 * 1000;
return timeSinceWarningMs >= gracePeriodMs;
}
private getInactivityThresholdMs(): number {
const thresholdDays = Config.inactivityDeletionThresholdDays ?? 365 * 2;
return thresholdDays * 24 * 60 * 60 * 1000;
}
private getWarningRedisKey(userId: UserID): string {
return `${this.INACTIVITY_WARNING_PREFIX}:${userId}`;
}
}

View File

@@ -0,0 +1,504 @@
/*
* 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 Stripe from 'stripe';
import {type ChannelID, createMessageID, createUserID, type MessageID, type UserID} from '~/BrandedTypes';
import {Config} from '~/Config';
import {ChannelTypes, MessageTypes, UserFlags} from '~/Constants';
import {mapChannelToResponse} from '~/channel/ChannelModel';
import type {ChannelRepository} from '~/channel/ChannelRepository';
import type {FavoriteMemeRepository} from '~/favorite_meme/FavoriteMemeRepository';
import type {GuildRepository} from '~/guild/repositories/GuildRepository';
import type {CloudflarePurgeQueue, NoopCloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
import type {DiscriminatorService} from '~/infrastructure/DiscriminatorService';
import type {GatewayService} from '~/infrastructure/GatewayService';
import {getMetricsService} from '~/infrastructure/MetricsService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {StorageService} from '~/infrastructure/StorageService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import {Logger} from '~/Logger';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {ApplicationRepository} from '~/oauth/repositories/ApplicationRepository';
import type {OAuth2TokenRepository} from '~/oauth/repositories/OAuth2TokenRepository';
import type {UserRepository} from '~/user/UserRepository';
import * as BucketUtils from '~/utils/BucketUtils';
import {randomString} from '~/utils/RandomUtils';
import type {WorkerService} from '~/worker/WorkerService';
function createRequestCache(): RequestCache {
return {
userPartials: new Map(),
clear: () => {},
};
}
const CHUNK_SIZE = 100;
const DELETED_USERNAME = '__deleted__';
export interface UserDeletionDependencies {
userRepository: UserRepository;
guildRepository: GuildRepository;
channelRepository: ChannelRepository;
favoriteMemeRepository: FavoriteMemeRepository;
oauth2TokenRepository: OAuth2TokenRepository;
storageService: StorageService;
cloudflarePurgeQueue: CloudflarePurgeQueue | NoopCloudflarePurgeQueue;
userCacheService: UserCacheService;
gatewayService: GatewayService;
snowflakeService: SnowflakeService;
discriminatorService: DiscriminatorService;
stripe: Stripe | null;
applicationRepository: ApplicationRepository;
workerService: WorkerService;
}
export async function processUserDeletion(
userId: UserID,
deletionReasonCode: number,
deps: UserDeletionDependencies,
): Promise<void> {
const {
userRepository,
guildRepository,
channelRepository,
favoriteMemeRepository,
oauth2TokenRepository,
storageService,
cloudflarePurgeQueue,
userCacheService,
gatewayService,
snowflakeService,
discriminatorService,
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) {
try {
Logger.debug({userId, subscriptionId: user.stripeSubscriptionId}, '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');
} catch (error) {
Logger.error(
{error, userId, subscriptionId: user.stripeSubscriptionId},
'Failed to cancel Stripe subscription, continuing with deletion',
);
}
}
const deletedUserId = createUserID(snowflakeService.generate());
Logger.debug({userId, deletedUserId}, 'Creating dedicated deleted user record');
let foundUsername: string;
let foundDiscriminator: number;
while (true) {
foundUsername = `DeletedUser${randomString(8)}`;
const discriminatorResult = await discriminatorService.generateDiscriminator({
username: foundUsername,
isPremium: false,
});
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
continue;
}
foundDiscriminator = discriminatorResult.discriminator;
break;
}
await userRepository.create({
user_id: deletedUserId,
username: foundUsername,
discriminator: foundDiscriminator,
global_name: 'Deleted User',
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,
first_refund_at: null,
beta_code_allowance: null,
beta_code_last_reset_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 cloudflarePurgeQueue.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 cloudflarePurgeQueue.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(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(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 lastChannelId: ChannelID | undefined;
let lastMessageId: MessageID | undefined;
let processedCount = 0;
while (true) {
const messagesToAnonymize = await channelRepository.listMessagesByAuthor(
userId,
CHUNK_SIZE,
lastChannelId,
lastMessageId,
);
if (messagesToAnonymize.length === 0) {
break;
}
for (const {channelId, messageId} of messagesToAnonymize) {
await channelRepository.anonymizeMessage(channelId, messageId, deletedUserId);
}
processedCount += messagesToAnonymize.length;
lastChannelId = messagesToAnonymize[messagesToAnonymize.length - 1].channelId;
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 cloudflarePurgeQueue.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 cloudflarePurgeQueue.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.deleteAllBetaCodes(userId),
userRepository.deleteAllRecentMentions(userId),
userRepository.deleteAllAuthorizedIps(userId),
userRepository.deletePendingVerification(userId),
userRepository.deletePinnedDmsByUserId(userId),
]);
await userRepository.deleteUserSecondaryIndices(userId);
Logger.debug({userId}, 'Anonymizing user record');
await userRepository.patchUpsert(userId, {
username: DELETED_USERNAME,
discriminator: 0,
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(),
});
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,471 @@
/*
* 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 '~/BrandedTypes';
import {MAX_RELATIONSHIPS, RelationshipTypes, UserFlags} from '~/Constants';
import {
AlreadyFriendsError,
BotsCannotHaveFriendsError,
CannotSendFriendRequestToBlockedUserError,
CannotSendFriendRequestToSelfError,
FriendRequestBlockedError,
InvalidDiscriminatorError,
MaxRelationshipsError,
NoUsersWithFluxertagError,
UnclaimedAccountRestrictedError,
UnknownUserError,
} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Relationship} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {type FriendRequestByTagRequest, mapRelationshipToResponse} from '~/user/UserModel';
import type {UserPermissionUtils} from '~/utils/UserPermissionUtils';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
import type {IUserRelationshipRepository} from '../repositories/IUserRelationshipRepository';
import {getCachedUserPartialResponse} from '../UserCacheHelpers';
export class UserRelationshipService {
constructor(
private userAccountRepository: IUserAccountRepository,
private userRelationshipRepository: IUserRelationshipRepository,
private gatewayService: IGatewayService,
private userPermissionUtils: UserPermissionUtils,
) {}
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 = Number.parseInt(discriminator, 10);
if (Number.isNaN(discrimValue) || discrimValue < 0 || discrimValue > 9999) {
throw new InvalidDiscriminatorError();
}
const targetUser = await this.userAccountRepository.findByUsernameDiscriminator(username, discrimValue);
if (!targetUser) {
throw new NoUsersWithFluxertagError();
}
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> {
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});
return await this.createFriendRequest({userId, targetId, userCacheService, requestCache});
}
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 && !user.passwordHash) {
throw new UnclaimedAccountRestrictedError('accept friend requests');
}
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 existingRelationship =
(await this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.FRIEND)) ||
(await this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST)) ||
(await this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST)) ||
(await this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.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<void> {
if (userId === targetId) {
throw new CannotSendFriendRequestToSelfError();
}
const requesterUser = await this.userAccountRepository.findUnique(userId);
if (requesterUser && !requesterUser.passwordHash) {
throw new UnclaimedAccountRestrictedError('send friend requests');
}
const targetUser = await this.userAccountRepository.findUnique(targetId);
if (!targetUser) throw new UnknownUserError();
if (targetUser.isBot) {
throw new BotsCannotHaveFriendsError();
}
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});
}
private async validateRelationshipCounts({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
const relationships = await this.userRelationshipRepository.listRelationships(userId);
if (relationships.length >= MAX_RELATIONSHIPS) {
throw new MaxRelationshipsError();
}
const targetRelationships = await this.userRelationshipRepository.listRelationships(targetId);
if (targetRelationships.length >= MAX_RELATIONSHIPS) {
throw new MaxRelationshipsError();
}
}
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},
});
}
}

View File

@@ -0,0 +1,610 @@
/*
* 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 '~/auth/AuthService';
import type {SudoVerificationResult} from '~/auth/services/SudoVerificationService';
import type {ChannelID, GuildID, MessageID, UserID} from '~/BrandedTypes';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import type {ChannelService} from '~/channel/services/ChannelService';
import type {GuildMemberResponse} from '~/guild/GuildModel';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {GuildService} from '~/guild/services/GuildService';
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
import type {EntityAssetService} from '~/infrastructure/EntityAssetService';
import type {IEmailService} from '~/infrastructure/IEmailService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import type {IStorageService} from '~/infrastructure/IStorageService';
import type {RedisAccountDeletionQueueService} from '~/infrastructure/RedisAccountDeletionQueueService';
import type {RedisBulkMessageDeletionQueueService} from '~/infrastructure/RedisBulkMessageDeletionQueueService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {
AuthSession,
BetaCode,
Channel,
GuildMember,
Message,
MfaBackupCode,
PushSubscription,
Relationship,
User,
UserGuildSettings,
UserSettings,
} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
import type {PackService} from '~/pack/PackService';
import type {
CreatePrivateChannelRequest,
FriendRequestByTagRequest,
UserGuildSettingsUpdateRequest,
UserSettingsUpdateRequest,
UserUpdateRequest,
} from '~/user/UserModel';
import type {SavedMessageStatus} from '~/user/UserTypes';
import type {UserPermissionUtils} from '~/utils/UserPermissionUtils';
import type {IWorkerService} from '~/worker/IWorkerService';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
import type {IUserAuthRepository} from '../repositories/IUserAuthRepository';
import type {IUserChannelRepository} from '../repositories/IUserChannelRepository';
import type {IUserContentRepository} from '../repositories/IUserContentRepository';
import type {IUserRelationshipRepository} from '../repositories/IUserRelationshipRepository';
import type {IUserSettingsRepository} from '../repositories/IUserSettingsRepository';
import type {UserHarvestResponse} from '../UserHarvestModel';
import {UserAccountService} from './UserAccountService';
import {UserAuthService} from './UserAuthService';
import {UserChannelService} from './UserChannelService';
import type {UserContactChangeLogService} from './UserContactChangeLogService';
import {UserContentService} from './UserContentService';
import {UserRelationshipService} from './UserRelationshipService';
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: IGuildRepository,
workerService: IWorkerService,
userPermissionUtils: UserPermissionUtils,
redisDeletionQueue: RedisAccountDeletionQueueService,
bulkMessageDeletionQueue: RedisBulkMessageDeletionQueueService,
botMfaMirrorService: BotMfaMirrorService,
contactChangeLogService: UserContactChangeLogService,
) {
this.accountService = new UserAccountService(
userAccountRepository,
userSettingsRepository,
userRelationshipRepository,
userChannelRepository,
authService,
userCacheService,
guildService,
gatewayService,
entityAssetService,
mediaService,
packService,
emailService,
rateLimitService,
guildRepository,
discriminatorService,
redisDeletionQueue,
contactChangeLogService,
);
this.authService = new UserAuthService(
userAccountRepository,
userAuthRepository,
authService,
emailService,
gatewayService,
botMfaMirrorService,
);
this.relationshipService = new UserRelationshipService(
userAccountRepository,
userRelationshipRepository,
gatewayService,
userPermissionUtils,
);
this.channelService = new UserChannelService(
userAccountRepository,
userChannelRepository,
userRelationshipRepository,
channelService,
channelRepository,
gatewayService,
mediaService,
snowflakeService,
userPermissionUtils,
);
this.contentService = new UserContentService(
userAccountRepository,
userContentRepository,
userCacheService,
channelService,
channelRepository,
gatewayService,
mediaService,
workerService,
snowflakeService,
bulkMessageDeletionQueue,
);
}
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}>;
}> {
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 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}): 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> {
return await this.relationshipService.acceptFriendRequest({userId, targetId, userCacheService, requestCache});
}
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 getBetaCodes(userId: UserID): Promise<Array<BetaCode>> {
return await this.contentService.getBetaCodes(userId);
}
async getBetaCodeAllowanceInfo(userId: UserID): Promise<{allowance: number; nextResetAt: Date | null}> {
return await this.contentService.getBetaCodeAllowanceInfo(userId);
}
async createBetaCode(userId: UserID): Promise<BetaCode> {
return await this.contentService.createBetaCode(userId);
}
async deleteBetaCode(params: {userId: UserID; code: string}): Promise<void> {
return await this.contentService.deleteBetaCode(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<{
channelId: ChannelID;
messageId: MessageID;
status: SavedMessageStatus;
message: Message | null;
}>
> {
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<{harvestId: 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<{downloadUrl: string; expiresAt: 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});
}
}